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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -101,7 +101,9 @@ export function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [team, setTeam] = useState<Team | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projectTotal, setProjectTotal] = useState(0);
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||
const [billing, setBilling] = useState<BillingSummary | null>(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 <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":
|
||||
return (
|
||||
<ProductsPage
|
||||
@@ -684,7 +688,7 @@ export function App() {
|
||||
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} />;
|
||||
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 (
|
||||
<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>
|
||||
<Decorations />
|
||||
<header className="topbar">
|
||||
|
||||
@@ -130,16 +130,19 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
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<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();
|
||||
|
||||
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -43,8 +47,8 @@ export function Dashboard({ products, projects, billing, userName, navigate }: {
|
||||
</button>
|
||||
</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="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="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 · {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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<tbody id="list-tbody">
|
||||
{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 (
|
||||
<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>
|
||||
</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>
|
||||
<div className="hstack">
|
||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user