From a0ed8666dfee0ea8934ae804ae4991022d8d208b Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 19:27:10 +0800 Subject: [PATCH] =?UTF-8?q?perf(core):=20=E9=A1=B9=E7=9B=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=B9=E8=BD=BB=E9=87=8F=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=20+=20=E8=AE=A1=E6=95=B0=E8=B5=B0=E7=9C=9F=E5=AE=9E=20count,?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E5=88=B7=E6=96=B0=202-3s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/projects/ 列表此前与详情共用重序列化器(嵌套 阶段/片段/故事板/时间线 + 每资产算 url), 20 个项目把全套关联拉出 → 实测 ~2-3s,正是每次刷新慢的主因。 - 新增 ProjectListSerializer(轻:名/状态/阶段/product/封面/脚本数/镜数); ProjectViewSet.get_serializer_class 列表用轻的、详情用全量; get_queryset 列表走轻查询(select_related product + annotate 计数,不做那串 12 个重 prefetch)。 实测 /api/projects/ 2-3s → 0.48s。 - 新增 /api/projects/summary/(total + by_status,只跑 COUNT,不拉项目对象)。 - 计数显示(侧栏徽标 / 仪表盘 总项目·SKU·N 个)改用分页响应的真实 count(productTotal/projectTotal), 不再用「已加载 20 条」的长度;projects 列表页脚本状态/镜数改读 *_count 字段。 闸:9 页全绿、无瀑布、API 全过;projects 端点提速 ~5x。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/projects/serializers.py | 21 ++++++++++++++++ core/backend/apps/projects/views.py | 28 ++++++++++++++++++++++ core/frontend/src/App.tsx | 10 +++++--- core/frontend/src/components/app-shell.tsx | 7 ++++-- core/frontend/src/routes/dashboard.tsx | 10 +++++--- core/frontend/src/routes/projects.tsx | 6 ++--- core/frontend/src/types.ts | 5 ++++ 7 files changed, 76 insertions(+), 11 deletions(-) diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 00c4b16..562acfd 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -245,6 +245,27 @@ class ScriptVersionSerializer(serializers.ModelSerializer): read_only_fields = fields +class ProjectListSerializer(serializers.ModelSerializer): + """列表/仪表盘/侧栏用的轻量项目序列化:不嵌套 阶段/片段/故事板/时间线(那些只详情页要)。 + 脚本数/镜数走 annotate 计数(见 ProjectViewSet.get_queryset),避免逐项目拉全套关联(原列表 2-3s)。""" + + product_title = serializers.CharField(source="product.title", read_only=True, default="") + cover_preview_url = serializers.SerializerMethodField() + script_version_count = serializers.IntegerField(read_only=True, default=0) + video_segment_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = Project + fields = [ + "id", "name", "product", "product_title", "cover_preview_url", + "status", "current_stage", "script_version_count", "video_segment_count", + "created_at", "updated_at", + ] + + def get_cover_preview_url(self, obj) -> str: + return _asset_preview_url(getattr(obj.product, "cover_asset", None)) if obj.product_id else "" + + class ProjectSerializer(serializers.ModelSerializer): stages = ProjectStageSerializer(many=True, read_only=True) video_segments = VideoSegmentSerializer(many=True, read_only=True) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 561294d..5b6a1cf 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -3,6 +3,7 @@ from pathlib import Path import uuid from django.db import transaction +from django.db.models import Count from django.http import JsonResponse, StreamingHttpResponse from rest_framework import status from rest_framework.decorators import action @@ -54,6 +55,7 @@ from .models import ( from .serializers import ( BaseAssetGroupSerializer, ExportJobSerializer, + ProjectListSerializer, ProjectSerializer, ScriptVersionSerializer, StoryboardVersionSerializer, @@ -133,6 +135,32 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): search_fields = ["name", "product__title"] ordering_fields = ["created_at", "updated_at", "name"] + def get_serializer_class(self): + # 列表用轻量序列化(只给列表/仪表盘/侧栏要的字段);详情/写操作用全量 ProjectSerializer + return ProjectListSerializer if self.action == "list" else ProjectSerializer + + def get_queryset(self): + # 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch + # ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。 + if self.action == "list": + qs = ( + Project.objects.select_related("product", "product__cover_asset") + .prefetch_related("product__cover_asset__files") + .annotate( + script_version_count=Count("script_versions", distinct=True), + video_segment_count=Count("video_segments", distinct=True), + ) + ) + return qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at") + return super().get_queryset() + + @action(detail=False, methods=["get"]) + def summary(self, request): + """项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。""" + base = Project.objects.filter(team=self.get_team()) + by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))} + return Response({"total": base.count(), "by_status": by_status}) + @transaction.atomic def perform_create(self, serializer): project = serializer.save(team=self.get_team(), created_by=self.request.user) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 22a3a31..1543eb9 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -101,7 +101,9 @@ export function App() { const [user, setUser] = useState(null); const [team, setTeam] = useState(null); const [products, setProducts] = useState([]); + const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用 const [projects, setProjects] = useState([]); + const [projectTotal, setProjectTotal] = useState(0); const [modelConfigs, setModelConfigs] = useState([]); const [billing, setBilling] = useState(null); const [unreadCount, setUnreadCount] = useState(0); @@ -144,7 +146,9 @@ export function App() { api.notificationsBadge().catch(() => null) ]); setProducts(productData.results); + setProductTotal(productData.count ?? productData.results.length); setProjects(projectData.results); + setProjectTotal(projectData.count ?? projectData.results.length); setModelConfigs(modelData?.results || []); if (billingData) setBilling(billingData); if (badgeData) setUnreadCount(badgeData.unread_count); @@ -543,7 +547,7 @@ export function App() { function renderPage() { switch (page) { case "dashboard": - return ; + return ; case "products": return ( setNotice({ type: "success", text })} onLogout={logout} />; default: - return ; + return ; } } @@ -809,7 +813,7 @@ export function App() { return (
- +
diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index fd32a45..5c16bd8 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -130,16 +130,19 @@ const PAGE_TO_NAV: Partial> = { settingsNotify: "settings" }; -export function Sidebar({ page, navigate, user, team, products, projects }: { +export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal }: { page: Page; navigate: Navigate; user: User; team: Team | null; products: Product[]; projects: Project[]; + productTotal?: number; + projectTotal?: number; }) { const activeNav = PAGE_TO_NAV[page]; - const badges: Partial> = { products: products.length, projects: projects.length }; + // 徽标用后端真实总数(分页 count),不用已加载的页内条数 + const badges: Partial> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length }; const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase(); // 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index 3e9b4f9..e0534ab 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -6,13 +6,17 @@ import { money, stageMeta, statusPill } from "./stage-config"; import { Progress } from "../components/pipeline-stage"; import { IconKitSvg } from "../components/IconKitSvg"; -export function Dashboard({ products, projects, billing, userName, navigate }: { +export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, navigate }: { products: Product[]; projects: Project[]; + productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示 + projectTotal?: number; billing: BillingSummary | null; userName?: string; navigate: (page: Page) => void; }) { + const projCount = projectTotal ?? projects.length; + const prodCount = productTotal ?? products.length; // 资产总数:走轻量 summary 接口(各 tab 计数之和),不再吃全局 assets 全量数组 const [assetCount, setAssetCount] = useState(null); useEffect(() => { @@ -43,8 +47,8 @@ export function Dashboard({ products, projects, billing, userName, navigate }: {
-
++
-

最近项目

{projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
+
++
+

最近项目

{projects.slice(0, 6).map((project) => )}

快捷入口

[ /shortcuts ]
navigate("products")} /> navigate("library")} /> navigate("account")} /> navigate("projects")} />

提示

[ FAQ ]
扣费规则生成失败、超时、用户重跑 — 均不扣费。仅在你点 [ 确认通过 ] 时按 token 实际结算。
); } diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 4e90d2c..cf02612 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -528,7 +528,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel if (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false; if (catFilter && productCat(project.product) !== catFilter) return false; if (sourceFilter !== "all") { - const hasScript = (project.script_versions?.length || 0) > 0; + const hasScript = (project.script_version_count ?? project.script_versions?.length ?? 0) > 0; if (sourceFilter === "has" && !hasScript) return false; if (sourceFilter === "none" && hasScript) return false; } @@ -663,7 +663,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel {pageItems.map((project) => { const no = projStageNo(project); - const shots = project.video_segments.length || 4; + const shots = project.video_segment_count ?? project.video_segments?.length ?? 4; const cover = projCover(project.name); return ( openPipeline(project.id)}> @@ -674,7 +674,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel {productTitle(project.product)} - {(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"} + {(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}
{[1, 2, 3, 4, 5].map((i) => )}
diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 56c249e..5f09b24 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -192,6 +192,11 @@ export type Project = { status: string; current_stage: string; failure_reason: string; + // 轻量列表序列化(ProjectListSerializer)专有字段;详情序列化不含,故可选 + product_title?: string; + cover_preview_url?: string; + script_version_count?: number; + video_segment_count?: number; stages: ProjectStage[]; script_versions: ScriptVersion[]; video_segments: VideoSegment[];