diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 4f1638a..3b4afd8 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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"] diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 30733f6..75ff8ee 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index e718e60..97015bc 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -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//", admin_quota_policy_detail, name="admin-quota-policy-detail"), + path("providers/", admin_providers, name="admin-providers"), + path("providers//", admin_provider_detail, name="admin-provider-detail"), + path("models/", admin_models, name="admin-models"), + path("models//", admin_model_detail, name="admin-model-detail"), + path("models//set-default/", admin_model_set_default, name="admin-model-set-default"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 4ccb404..662c529 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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) diff --git a/core/backend/apps/ai/migrations/0010_modelconfig_is_default.py b/core/backend/apps/ai/migrations/0010_modelconfig_is_default.py new file mode 100644 index 0000000..054987d --- /dev/null +++ b/core/backend/apps/ai/migrations/0010_modelconfig_is_default.py @@ -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), + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 8d816d0..9fe4f5c 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -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: diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 117b1f5..d532fed 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 一律 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index b3e69d0..105f752 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1,5 +1,7 @@ import type { AdminLedger, + AdminModel, + AdminProvider, AdminQualityWord, AdminQuotaPolicy, AdminReviewAsset, @@ -648,5 +650,36 @@ export const adminApi = { }, deleteQuotaPolicy(id: string) { return request(`/api/admin/quota-policies/${id}/`, { method: "DELETE" }); + }, + providers() { + return request("/api/admin/providers/"); + }, + createProvider(payload: { name: string; display_name: string; base_url?: string; api_key?: string; status?: string }) { + return request("/api/admin/providers/", { method: "POST", body: JSON.stringify(payload) }); + }, + updateProvider(id: string, payload: Record) { + return request(`/api/admin/providers/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteProvider(id: string) { + return request(`/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(`/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("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) }); + }, + updateModel(id: string, payload: Record) { + return request(`/api/admin/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteModel(id: string) { + return request(`/api/admin/models/${id}/`, { method: "DELETE" }); + }, + setDefaultModel(id: string) { + return request(`/api/admin/models/${id}/set-default/`, { method: "POST" }); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index f2812d9..23e8cf1 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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 ; } + if (section.slug === "providers") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-models.tsx b/core/frontend/src/routes/admin/admin-models.tsx new file mode 100644 index 0000000..d573668 --- /dev/null +++ b/core/frontend/src/routes/admin/admin-models.tsx @@ -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 + ? 启用 + : 停用; +} + +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([]); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [provModal, setProvModal] = useState(null); + const [modelModal, setModelModal] = useState(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 = { 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 ( + <> +
+
+

模型供应商

+
// {providers.length} 供应商 · {models.length} 模型 · 热插拔中转站 · 定价 · 设默认
+
+
+ + +
+
+ + {loading ? ( +

加载中…

// fetching providers

+ ) : ( + <> +
供应商
+
+ + + + {providers.map((p) => ( + + + + + + + + + + ))} + +
名称显示名Base URL密钥模型数状态操作
{p.name}{p.display_name}{p.base_url || }{p.has_api_key ? 已配 : }{p.model_count}{statusPill(p.status === "active")} + + +
+
+ +
模型
+
+ + + + {models.map((m) => ( + + + + + + + + + + ))} + +
供应商模型能力单价状态默认操作
{m.provider_name}{m.display_name || m.name}{m.capability}¥{m.unit_price}{statusPill(m.status === "active")}{m.is_default ? 默认 : } + {!m.is_default && } + + + +
+
+ + )} + + {provModal && ( +
{ if (e.target === e.currentTarget) setProvModal(null); }}> +
+ ++ +
+
+
{provModal.id ? "编辑供应商" : "新建供应商"}// model provider
+ +
+
+
+ + setProvModal((p) => p && ({ ...p, name: e.target.value }))} /> +
+
+ + setProvModal((p) => p && ({ ...p, display_name: e.target.value }))} /> +
+
+ + setProvModal((p) => p && ({ ...p, base_url: e.target.value }))} /> +
+
+ + setProvModal((p) => p && ({ ...p, api_key: e.target.value }))} /> +
+
+
+ + +
+
+
+ )} + + {modelModal && ( +
{ if (e.target === e.currentTarget) setModelModal(null); }}> +
+ ++ +
+
+
{modelModal.id ? "编辑模型" : "新建模型"}// model config
+ +
+
+
+ + +
+
+
+ + setModelModal((m) => m && ({ ...m, name: e.target.value }))} /> +
+
+ + +
+
+
+ + setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} /> +
+
+
+ + setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} /> +
+
+ + setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} /> +
+
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 0dd3e8a..bbd4865 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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; + 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; + created_at: string; +}; export type Paginated = { count: number; diff --git a/core/qa/visual-parity/_admin-p8.mjs b/core/qa/visual-parity/_admin-p8.mjs new file mode 100644 index 0000000..7f16780 --- /dev/null +++ b/core/qa/visual-parity/_admin-p8.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index adf191d..1840427 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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//`(启停 / 改 base_url / 改 key);**api_key write-only**(只写不回传,列表给 has_api_key 布尔)。 +- models:`GET/POST models/`(?provider/capability 筛)+ `PATCH/DELETE models//`(改定价 / 启停)+ `POST models//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` 全绿。