perf(core): 项目列表改轻量序列化 + 计数走真实 count,根治刷新 2-3s

/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 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 19:27:10 +08:00
co-authored by Claude Opus 4.8
parent c62abed536
commit a0ed8666df
7 changed files with 76 additions and 11 deletions
+21
View File
@@ -245,6 +245,27 @@ class ScriptVersionSerializer(serializers.ModelSerializer):
read_only_fields = fields 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): class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True) stages = ProjectStageSerializer(many=True, read_only=True)
video_segments = VideoSegmentSerializer(many=True, read_only=True) video_segments = VideoSegmentSerializer(many=True, read_only=True)
+28
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import uuid import uuid
from django.db import transaction from django.db import transaction
from django.db.models import Count
from django.http import JsonResponse, StreamingHttpResponse from django.http import JsonResponse, StreamingHttpResponse
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -54,6 +55,7 @@ from .models import (
from .serializers import ( from .serializers import (
BaseAssetGroupSerializer, BaseAssetGroupSerializer,
ExportJobSerializer, ExportJobSerializer,
ProjectListSerializer,
ProjectSerializer, ProjectSerializer,
ScriptVersionSerializer, ScriptVersionSerializer,
StoryboardVersionSerializer, StoryboardVersionSerializer,
@@ -133,6 +135,32 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["name", "product__title"] search_fields = ["name", "product__title"]
ordering_fields = ["created_at", "updated_at", "name"] 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 @transaction.atomic
def perform_create(self, serializer): def perform_create(self, serializer):
project = serializer.save(team=self.get_team(), created_by=self.request.user) project = serializer.save(team=self.get_team(), created_by=self.request.user)
+7 -3
View File
@@ -101,7 +101,9 @@ export function App() {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [team, setTeam] = useState<Team | null>(null); const [team, setTeam] = useState<Team | null>(null);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [projectTotal, setProjectTotal] = useState(0);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]); const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null); const [billing, setBilling] = useState<BillingSummary | null>(null);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
@@ -144,7 +146,9 @@ export function App() {
api.notificationsBadge().catch(() => null) api.notificationsBadge().catch(() => null)
]); ]);
setProducts(productData.results); setProducts(productData.results);
setProductTotal(productData.count ?? productData.results.length);
setProjects(projectData.results); setProjects(projectData.results);
setProjectTotal(projectData.count ?? projectData.results.length);
setModelConfigs(modelData?.results || []); setModelConfigs(modelData?.results || []);
if (billingData) setBilling(billingData); if (billingData) setBilling(billingData);
if (badgeData) setUnreadCount(badgeData.unread_count); if (badgeData) setUnreadCount(badgeData.unread_count);
@@ -543,7 +547,7 @@ export function App() {
function renderPage() { function renderPage() {
switch (page) { switch (page) {
case "dashboard": case "dashboard":
return <Dashboard products={products} projects={projects} billing={billing} userName={currentUser.username} navigate={navigate} />; return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} navigate={navigate} />;
case "products": case "products":
return ( return (
<ProductsPage <ProductsPage
@@ -684,7 +688,7 @@ export function App() {
case "settingsNotify": case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />; return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default: default:
return <Dashboard products={products} projects={projects} billing={billing} navigate={navigate} />; return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} navigate={navigate} />;
} }
} }
@@ -809,7 +813,7 @@ export function App() {
return ( return (
<div className="app"> <div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} /> <Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} />
<main> <main>
<Decorations /> <Decorations />
<header className="topbar"> <header className="topbar">
+5 -2
View File
@@ -130,16 +130,19 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
settingsNotify: "settings" settingsNotify: "settings"
}; };
export function Sidebar({ page, navigate, user, team, products, projects }: { export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal }: {
page: Page; page: Page;
navigate: Navigate; navigate: Navigate;
user: User; user: User;
team: Team | null; team: Team | null;
products: Product[]; products: Product[];
projects: Project[]; projects: Project[];
productTotal?: number;
projectTotal?: number;
}) { }) {
const activeNav = PAGE_TO_NAV[page]; const activeNav = PAGE_TO_NAV[page];
const badges: Partial<Record<string, number>> = { products: products.length, projects: projects.length }; // 徽标用后端真实总数(分页 count),不用已加载的页内条数
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase(); const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed // 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
+7 -3
View File
@@ -6,13 +6,17 @@ import { money, stageMeta, statusPill } from "./stage-config";
import { Progress } from "../components/pipeline-stage"; import { Progress } from "../components/pipeline-stage";
import { IconKitSvg } from "../components/IconKitSvg"; 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[]; products: Product[];
projects: Project[]; projects: Project[];
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
projectTotal?: number;
billing: BillingSummary | null; billing: BillingSummary | null;
userName?: string; userName?: string;
navigate: (page: Page) => void; navigate: (page: Page) => void;
}) { }) {
const projCount = projectTotal ?? projects.length;
const prodCount = productTotal ?? products.length;
// 资产总数:走轻量 summary 接口(各 tab 计数之和),不再吃全局 assets 全量数组 // 资产总数:走轻量 summary 接口(各 tab 计数之和),不再吃全局 assets 全量数组
const [assetCount, setAssetCount] = useState<number | null>(null); const [assetCount, setAssetCount] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
@@ -43,8 +47,8 @@ export function Dashboard({ products, projects, billing, userName, navigate }: {
</button> </button>
</div> </div>
</div> </div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</div></button></div> <div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={`↑ 本月 +${Math.max(projCount, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="商品库" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="资产库" desc={`${assetCount ?? "…"} 资产`} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="所有项目" desc={`${projects.length} 个`} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2>提示</h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong>扣费规则</strong>生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span className="mono">[ 确认通过 ]</span> 时按 token 实际结算。</div></div></div></div> <div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] →</button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI 全生 / 4 镜</div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="商品库" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="资产库" desc={`${assetCount ?? "…"} 资产`} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="所有项目" desc={`${projCount} 个`} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2>提示</h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong>扣费规则</strong>生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span className="mono">[ 确认通过 ]</span> 时按 token 实际结算。</div></div></div></div>
</> </>
); );
} }
+3 -3
View File
@@ -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 (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false;
if (catFilter && productCat(project.product) !== catFilter) return false; if (catFilter && productCat(project.product) !== catFilter) return false;
if (sourceFilter !== "all") { 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 === "has" && !hasScript) return false;
if (sourceFilter === "none" && hasScript) return false; if (sourceFilter === "none" && hasScript) return false;
} }
@@ -663,7 +663,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<tbody id="list-tbody"> <tbody id="list-tbody">
{pageItems.map((project) => { {pageItems.map((project) => {
const no = projStageNo(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); const cover = projCover(project.name);
return ( return (
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}> <tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}>
@@ -674,7 +674,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</div> </div>
</td> </td>
<td>{productTitle(project.product)}</td> <td>{productTitle(project.product)}</td>
<td><span className="muted">{(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td> <td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
<td> <td>
<div className="hstack"> <div className="hstack">
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div> <div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
+5
View File
@@ -192,6 +192,11 @@ export type Project = {
status: string; status: string;
current_stage: string; current_stage: string;
failure_reason: string; failure_reason: string;
// 轻量列表序列化(ProjectListSerializer)专有字段;详情序列化不含,故可选
product_title?: string;
cover_preview_url?: string;
script_version_count?: number;
video_segment_count?: number;
stages: ProjectStage[]; stages: ProjectStage[];
script_versions: ScriptVersion[]; script_versions: ScriptVersion[];
video_segments: VideoSegment[]; video_segments: VideoSegment[];