优化后台
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 535 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 256 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -358,19 +358,13 @@ export function App() {
|
||||
}, [page, route.projectId, route.productId]);
|
||||
|
||||
// 平台后台 gating(身份就绪后):
|
||||
// - 平台超管打开站点根路径 / 登录页:直接进 /admin,不先落全能创作再点侧栏
|
||||
// - 无团队的超管:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
|
||||
// - 平台超管不进工作台:任何非 /admin 路由都送回后台
|
||||
// - 非超管访问 /admin/*:纠回工作台
|
||||
// 已有团队的超管从后台点「返回工作台」会落到 /omni-create,这里不拦。
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user?.is_platform_admin || route.admin !== undefined) return;
|
||||
const path = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const defaultEntry = path === "/" || path === "/login" || path === "/dashboard";
|
||||
if (!team || defaultEntry) {
|
||||
navigateAdmin("", { replace: true });
|
||||
}
|
||||
navigateAdmin("", { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, team, route.admin]);
|
||||
}, [booting, user, route.admin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (booting || !user) return;
|
||||
@@ -396,12 +390,11 @@ export function App() {
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user || page !== "dashboard") return;
|
||||
if (route.admin !== undefined) return;
|
||||
const path = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
// 同一次渲染里超管入口已经改去 /admin,这里不能再改写成全能创作。
|
||||
if (user.is_platform_admin && (path === "/" || path === "/login" || path === "/dashboard" || !team)) return;
|
||||
// 超管由上面的后台 gating 接走,这里不能再改写成全能创作。
|
||||
if (user.is_platform_admin) return;
|
||||
navigate("omniCreate", { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, team, page, route.admin]);
|
||||
}, [booting, user, page, route.admin]);
|
||||
|
||||
// Load preferences + sessions when entering settings.
|
||||
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
||||
@@ -901,14 +894,10 @@ export function App() {
|
||||
setTeam(payload.team);
|
||||
setRole(payload.role || "");
|
||||
setBooting(false);
|
||||
// 平台超管登录后直接进后台。有团队时后台补拉工作台数据,供「返回工作台」使用;
|
||||
// 无团队则不拉团队级接口(products/projects 会因无团队报错)。
|
||||
// 平台超管只进后台,不拉工作台数据。
|
||||
if (payload.user.is_platform_admin) {
|
||||
setAuthed(true);
|
||||
navigateAdmin("", { replace: true });
|
||||
if (payload.team) {
|
||||
loadDataWithRetry().catch((error) => console.error("[login] data hydrate failed:", error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigate("omniCreate", { replace: true });
|
||||
@@ -986,9 +975,7 @@ export function App() {
|
||||
<AdminApp
|
||||
section={route.admin}
|
||||
user={user}
|
||||
team={team}
|
||||
navigateAdmin={navigateAdmin}
|
||||
navigate={navigate}
|
||||
logout={logout}
|
||||
/>
|
||||
{sessionInvalidatedModal}
|
||||
|
||||
@@ -90,24 +90,6 @@
|
||||
border-top: 1px solid var(--st-line);
|
||||
background: rgba(28, 34, 43, 0.02);
|
||||
}
|
||||
.admin-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--st-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-back:hover { color: var(--st-text); background: var(--st-hover); }
|
||||
.admin-back svg { width: 16px; height: 16px; }
|
||||
|
||||
.admin-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -113,9 +113,15 @@
|
||||
|
||||
.models-page .ml-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
.models-page .ml-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -281,7 +287,10 @@
|
||||
.models-page .ml-sel-bar { left: 50%; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); }
|
||||
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* 模特详情弹窗:参考视频项目角色详情结构 */
|
||||
|
||||
@@ -879,11 +879,27 @@
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.omni-case-visual img {
|
||||
transition: transform 260ms ease;
|
||||
}
|
||||
|
||||
/* 预览视频叠在封面上。播不起来时保持透明,封面继续可见。
|
||||
不要给 video 加 scale:Safari 里变换过的 video 会直接画成空白。 */
|
||||
.omni-case-visual video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.omni-case-visual video.is-playing {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.omni-case-card:hover .omni-case-visual img,
|
||||
.omni-case-card:hover .omni-case-visual video {
|
||||
.omni-case-card.active .omni-case-visual img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, LogOut } from "lucide-react";
|
||||
import { ArrowRight, LogOut } from "lucide-react";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
|
||||
import { ConfirmModal } from "../../components/overlays";
|
||||
import type { Team, User } from "../../types";
|
||||
import type { NavigateFn } from "../route-config";
|
||||
import type { User } from "../../types";
|
||||
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
|
||||
import { AdminGovernancePage } from "./admin-governance";
|
||||
import { AdminInvitesPage } from "./admin-invites";
|
||||
@@ -70,13 +69,11 @@ const GROUP_HINT: Record<string, string> = {
|
||||
type AdminAppProps = {
|
||||
section: string;
|
||||
user: User;
|
||||
team: Team | null;
|
||||
navigateAdmin: (section: string, options?: { replace?: boolean }) => void;
|
||||
navigate: NavigateFn;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export function AdminApp({ section, user, team, navigateAdmin, navigate, logout }: AdminAppProps) {
|
||||
export function AdminApp({ section, user, navigateAdmin, logout }: AdminAppProps) {
|
||||
const active = ADMIN_SECTIONS.find((s) => s.slug === section) || ADMIN_SECTIONS[0];
|
||||
const [toast, setToast] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null);
|
||||
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||
@@ -143,12 +140,6 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
|
||||
})}
|
||||
</div>
|
||||
<div className="admin-foot">
|
||||
{team && (
|
||||
<button type="button" className="admin-back" onClick={() => navigate("dashboard")}>
|
||||
<ArrowLeft size={16} strokeWidth={1.8} />
|
||||
返回工作台
|
||||
</button>
|
||||
)}
|
||||
<div className="admin-user">
|
||||
<div className="av">{(user.username || "A").slice(0, 1).toUpperCase()}</div>
|
||||
<div className="admin-user-meta">
|
||||
|
||||
@@ -71,6 +71,37 @@ const IMAGE_PRESETS: PresetItem[] = [
|
||||
const MENTION_REF_LIMIT = 20;
|
||||
const ROLE_REF_LIMIT = 3;
|
||||
|
||||
function playPresetPreview(card: HTMLElement) {
|
||||
const video = card.querySelector<HTMLVideoElement>("video");
|
||||
if (!video || video.dataset.broken === "1") return;
|
||||
const reveal = () => {
|
||||
if (video.dataset.broken === "1" || video.videoWidth === 0) return;
|
||||
video.classList.add("is-playing");
|
||||
};
|
||||
const fail = () => {
|
||||
video.dataset.broken = "1";
|
||||
video.classList.remove("is-playing");
|
||||
video.pause();
|
||||
};
|
||||
video.addEventListener("error", fail, { once: true });
|
||||
video.addEventListener("playing", reveal, { once: true });
|
||||
void video.play().catch(fail);
|
||||
}
|
||||
|
||||
function stopPresetPreview(card: HTMLElement) {
|
||||
const video = card.querySelector<HTMLVideoElement>("video");
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
if (video.dataset.broken !== "1" && Number.isFinite(video.duration)) {
|
||||
try {
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
/* Safari 在还没解码时不允许回绕 */
|
||||
}
|
||||
}
|
||||
video.classList.remove("is-playing");
|
||||
}
|
||||
|
||||
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
|
||||
{ type: "asset", label: "素材", Icon: ImageIcon },
|
||||
{ type: "character", label: "角色", Icon: UserRound },
|
||||
@@ -671,39 +702,22 @@ export function OmniCreatePage({
|
||||
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
|
||||
data-mode={card.mode}
|
||||
key={card.name}
|
||||
onMouseEnter={(event) => {
|
||||
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
||||
void video?.play().catch(() => undefined);
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
||||
void video?.play().catch(() => undefined);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}}
|
||||
onMouseEnter={(event) => playPresetPreview(event.currentTarget)}
|
||||
onMouseLeave={(event) => stopPresetPreview(event.currentTarget)}
|
||||
onFocus={(event) => playPresetPreview(event.currentTarget)}
|
||||
onBlur={(event) => stopPresetPreview(event.currentTarget)}
|
||||
onClick={() => {
|
||||
if (card.mode === "image") applyPreset(card);
|
||||
else setPreviewCase(card);
|
||||
}}
|
||||
>
|
||||
<span className="omni-case-visual">
|
||||
<img src={card.cover} alt="" />
|
||||
{card.previewVideo ? (
|
||||
<video muted loop playsInline preload="metadata" poster={card.cover} aria-label={`${card.title} 样片预览`}>
|
||||
<video muted loop playsInline preload="none" poster={card.cover} aria-hidden="true">
|
||||
<source src={card.previewVideo} type="video/mp4" />
|
||||
</video>
|
||||
) : (
|
||||
<img src={card.cover} alt="" />
|
||||
)}
|
||||
) : null}
|
||||
</span>
|
||||
<span className="omni-case-copy">
|
||||
<strong>{card.title}</strong>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# AirShelf · 性能审计报告
|
||||
|
||||
> **范围:** 全量扫描 —— 前端(数据加载/水合、列表渲染、pipeline 重渲染、全站轮询)+ 后端(ORM N+1、端点耗时/payload、数据库索引)
|
||||
> **背景:** 用户反馈刷新/进页面拉数据慢、不够丝滑;张业昌已优化过一波,仍有体感卡顿
|
||||
> **日期:** 2026-06-19
|
||||
> **方法:** 6 路并行审计,每块两端源码核查到 file:line
|
||||
> **严重度:** P0 = 明显卡顿/秒级阻塞 · P1 = 可感知延迟 · P2 = 优化空间
|
||||
|
||||
---
|
||||
|
||||
## 〇、先说结论:"刷新慢/不丝滑"的真正元凶
|
||||
|
||||
按"改动成本 vs 体感收益"排序。**前两条是一行/小改就能立竿见影的,强烈建议先做。**
|
||||
|
||||
| # | 根因 | 类型 | 现状 | 一句话修复 | 收益 |
|
||||
|---|---|---|---|---|---|
|
||||
| 🔴 1 | **线上 DB 连接不复用** | 后端配置 | `production.py` 缺 `CONN_MAX_AGE`,每请求重连远程 MySQL ~1.6s;并发 N 接口=N×1.6s。development 有这条优化,**线上没搬** | production.py 加 `CONN_MAX_AGE=300`+`CONN_HEALTH_CHECKS` | **刷新整页快几十秒→秒级** |
|
||||
| 🔴 2 | **登录假动画延迟 1.5s** | 前端 | 凭证验过后还硬演 verified+entering 两段共 ~1.5s | **已修**(见本报告末) | 登录即时进 |
|
||||
| 🔴 3 | **视频生成期 5s 全量回拉项目详情** | 前后端 | 有视频在跑时每 5s 调 `api.project()`,跑最重的 12-prefetch 序列化器、回 ~26KB,整页每 5 秒抖一下 | 轮询改用轻量 `poll-video-segment` 单段状态 | 视频阶段不再整页抖、payload 砍 99% |
|
||||
| 🔴 4 | **消息中心首屏同步 N+1 爆炸** | 后端 | 进消息面板时 `list()` 里同步循环 aggregate+get_or_create,单次可达 **500~1000+ 条 SQL** | 批量聚合 + bulk_create + 改 Celery 异步 | 打开消息面板秒回 |
|
||||
| 🔴 5 | **Asset 大表零索引** | 后端索引 | 全站最大增长表,资产库列表/计数/facets 全表扫 + filesort | 加 `(team,category,-created_at)` 等复合索引 | 资产多时数量级提速 |
|
||||
| 🔴 6 | **pipeline 3100 行巨页不拆不 memo** | 前端 | 任一 state 变(打字/轮询/hover)reconcile 整棵树 | 5 个 stage 拆 memo 子组件 + 派生数据 useMemo | 输入/拖动恢复跟手 |
|
||||
| 🟠 7 | **切走再回必重拉、无缓存** | 前端 | 换页即卸载,每次进页面从零拉、白骨架闪烁 | 加内存缓存 + stale-while-revalidate | 二次进页 0 等待 |
|
||||
| 🟠 8 | **大页一次拉 200 条 + img 漏 lazy** | 前端 | 商品详情/图片工具 `pageSize:200`,任务中心缩图无 `loading=lazy` | 改服务端分页 + 补 lazy | 首屏字节/解码降一个量级 |
|
||||
|
||||
---
|
||||
|
||||
## 一、前端 · 数据加载与水合(7/10)
|
||||
|
||||
> 水合链路已认真优化过:骨架先出、`loadData` 并行 Promise.all、不阻塞首屏、liteRefresh、懒加载——核心卡顿点基本消除。剩下的是缓存与"小操作全量重拉"。
|
||||
|
||||
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|
||||
|---|---|---|---|---|
|
||||
| P1 | 各路由 + `App.tsx` renderPage switch | **切走再回每次都重拉**:换页即卸载旧组件,`reloadFlag` 归零,资产库/消息/团队/消费/图片工具/商品详情/工作台进去都从零拉,无任何缓存 | 反复横跳每次白骨架闪烁,不丝滑 | 模块级内存缓存或 SWR:先渲染上次结果,再后台 revalidate |
|
||||
| P1 | `App.tsx:387` `action()` | **几乎所有变更都触发全量 `loadData()`**(并发重拉 products+projects+billing+models+badge 五个);只有 pipeline 改/加/删分镜传了 liteRefresh | 改商品/传图/改资料后顶栏侧栏整体抖一下,且并发拉无关数据 | 给不动列表的操作普遍补 `liteRefresh`,或让调用方只刷相关切片 |
|
||||
| P1 | `App.tsx:388` `action()` | **每次 action 无条件 `refreshProjectDetail()`**,即便不在 pipeline、操作与项目无关(改头像/充值/建商品都白拉 26KB 项目详情) | 非管线页做操作时白发一个大请求 | 仅 `page==="pipeline"` 或操作确属项目相关时才刷 |
|
||||
| P2 | `ai-tools.tsx:94`/`products.tsx:551`/`:568` | `pageSize:200` 一次拉大页再前端筛/分页 | 资产多的团队首屏偏慢 | 改服务端分页 + 触底加载 |
|
||||
| P2 | 站内点进管线 `App.tsx:759-786` | 详情未到时整页 loading,未与导航预取重叠 | 从项目列表点进管线有一次明显等待 | `openPipeline` 时即并行预取 `api.project(id)` |
|
||||
|
||||
✅ 已核实无问题:loadData 并行且不阻塞首屏、骨架态完整、搜索去抖到位、防连点到位、**无人为延迟卡流程**(除已修的登录)。
|
||||
|
||||
---
|
||||
|
||||
## 二、前端 · 列表与图片渲染(6.5/10)
|
||||
|
||||
> 分页/懒加载/去抖基础都对,但几处"大页一次拉 + 一次性渲染"和漏 lazy,会在素材多的真实账号上突然变卡。
|
||||
|
||||
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | 商品详情 `products.tsx:551` | 进详情一次拉该商品**最多 200 条**素材,前端再 filter+slice 只显示 12,200 条全进 state | 素材多的商品点进去明显"转一下" | 服务端按页拉(page+pageSize=12),"加载更多"走下一页 |
|
||||
| **P0** | 图片工具任务中心 `ai-tools.tsx:314,349` | 结果缩图 `<img>` **漏 `loading="lazy"`**(同文件别处都加了)+ `:94` 一次拉 200 张,首屏并发几十张原图 | 进"图片生成"页一批图齐刷、带宽抖动 | 两处补 `loading="lazy"`,源数据改分页 |
|
||||
| **P0** | 商品库/项目页 `App.tsx:141-142`+`api.ts:175,204` | `api.products()/projects()` 只取后端**第 1 页 20 条**,却被当"全量"做客户端搜索/筛选/分页 → 第 21 个起根本不在内存 | **搜不到/筛不到第 20 名以后的数据**,用户误判"卡了没加载" | products/projects 改服务端分页(像 library/messages 那样) |
|
||||
| P1 | 图片工具模特网格 `ai-tools.tsx:568` | 一次拉 200 个模特进 state,点"全部模特"渲染全部缩图 | 模特库大时工作台首开+展开卡 | 默认拉前若干,"全部"走分页 |
|
||||
| P1 | 商品详情视频 tab `products.tsx:643-646` | `videoProjects` 不分页不截断,一次渲染全部视频卡(assets tab 有 limit,videos 没有) | 项目多的商品切到视频 tab 一次铺满 | videos tab 也加 limit/分页 |
|
||||
| P1 | 向导商品选择器 `projects.tsx:278-280` | 页码用 `Array.from({length:totalPages})` 全量展开,没用 pageWindow 折叠 | 商品多时页码一长条 | 复用 `pageWindow()` 折叠 |
|
||||
| P2 | 商品库 `products.tsx:124-134` | 搜索纯客户端但每键触发整列表重算;`ProductCard` 未 memo,map 内联闭包 | 商品多时逐字搜索略顿 | ProductCard 包 memo,filtered 用 useMemo |
|
||||
| P2 | 列表行未 memo / key 用 index | `projects.tsx:588`/`account.tsx:331,347` 内联闭包无 memo;`products.tsx:435,819` key=index | 当前每页 10 条无碍,数据增长后退化 | 列表项抽组件 memo,key 用稳定 id |
|
||||
|
||||
✅ 正向:六个页面 loading 都用骨架不白屏;messages/library/account 已是真服务端分页+去抖,messages 还做了滚动无限加载+防竞态。
|
||||
|
||||
---
|
||||
|
||||
## 三、前端 · pipeline 重渲染与轮询(5/10)
|
||||
|
||||
> 轮询清理/动画做得不错,但**单个 3100 行巨页 + 5s 全量回拉**让整页每 5 秒抖,后台 tab 也照拉不停。这是 pipeline"不丝滑"的核心。
|
||||
|
||||
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | `App.tsx:320-334`+`api.ts:207` | 视频 5s 轮询每轮 `api.project()` 拉**全量 ~26KB**再 `setProjectDetail`,无脏检查,即便没变也 set → 整页重渲染 | 有视频在跑时整页每 5 秒"刷一下",滚动/输入发顿 | 后端加轻量 `video-status` 端点;或 set 前对比版本号/JSON 跳过 |
|
||||
| **P0** | `pipeline.tsx:445`(整组件)+`503-585` | 单个 3100 行函数持 60+ useState,5 阶段全在一棵树;`byId/scripts/shots/groups` 每次渲染重算(memo 远不够),组件无 memo 无拆分 | 任一 state 变(打字/轮询/hover)reconcile 整棵巨树,输入延迟 | 5 阶段拆 memo 子组件只渲当前 stage;派生数据包 useMemo |
|
||||
| P1 | 全站轮询无 `visibilitychange` | 5s/8s/4s 轮询在**隐藏 tab/切后台时照跑**(全局 0 命中 document.hidden) | 切走后仍持续打接口,耗电耗流量,服务端空转 | poll tick 起始加 `if(document.hidden)return` |
|
||||
| P1 | `pipeline.tsx:2076-2095` | stage2 `pendingAssets` 4s 轮询**空闲也永不停**,`list.length==0` 也照 setTimeout | 停在资产页啥也不干也每 4s 打接口 | 连续 N 轮空就停,有新提交再启 |
|
||||
| P2 | `pipeline.tsx:84-89` `getAssetBlobUrl` | `createObjectURL` 进 module Map 缓存后**全程不 revoke**,跨项目累积 | 长会话内存缓慢上涨 | 切/卸载项目时 revoke + 清 Map |
|
||||
|
||||
✅ 已核实无问题:ProgressTimeline 的 1s interval 作用域小且 done 即停;两处 rAF 正确 gate+cleanup;5s/8s/120ms 轮询都在卸载时清理且按阶段 gate;CSS infinite 动画都是条件出现的 spinner/shimmer;无人为卡流程的 setTimeout。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端 · ORM N+1 与序列化器(核心路径已治理,集中 2 个热点)
|
||||
|
||||
> 详情/列表核心路径已被认真优化(prefetch、defer 大 JSON 列、annotate 计数、轻量列表序列化器)。真正的放大点集中在消息中心首屏 + 写操作回吐全量。
|
||||
|
||||
| 严重度 | 位置 | 问题(估算多发 SQL) | 体感影响 | 修复建议 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | `apps/ops/views.py:69-235` `ensure_team_notifications`(在 `list()` 同步触发) | 首屏循环内:5 项目各 `credit_ledgers.aggregate()` + 拉最多 500 条 charge 流水逐条 `get_or_create`。**单次首屏 500~1000+ 条往返**,远程库几秒~几十秒 | 第一次开消息面板/换团队整页长转圈 | 项目花费 `values('project').annotate(Sum)` 批量;charge 通知批量查 dedupe_key + bulk_create;首屏走 Celery 异步 |
|
||||
| P1 | `apps/ops/views.py:285-292` `type_counts` | **每次**列表请求额外 6 条 `.count()`(all/unread/task/team/billing/system) | 收件箱翻页每次多 6 个往返 | 一次 `values('notification_type').annotate(Count)`+单 unread count = 2 条 |
|
||||
| P1 | `apps/projects/views.py` 写 action 一律回 `ProjectSerializer(project).data`(`:491/544/681/781` 等) | 每个采用版本/上传/存草稿按钮都回吐**整棵项目树**(几十~上百行),个别 action 未走 prefetch 还会 N+1 | 每次写操作后界面可感卡顿 + 回包巨大 | 只回受影响子对象(已有 VideoSegmentVersionSerializer 等);必须回全量则走带 prefetch 的重取 |
|
||||
| P2 | `apps/projects/serializers.py:90-100` `BaseAssetGroupSerializer` | 线索核实:列表/详情**不 N+1**(queryset 已 prefetch adopted_asset/candidate/files)。仅 `adopt_base_asset` 单独序列化未预取的 group 时每组 +2~3 条 | 采用基础资产后单次轻微多查 | 单对象序列化前补 select_related/prefetch |
|
||||
| P2 | `apps/assets/views.py:98-118` summary/facets | 资产库徽标 6 条 count、facets 多条 distinct(固定不放大) | 进资产库一组并发 count | 可合成一条 `aggregate(Count(Case(When)))` |
|
||||
|
||||
✅ 已核实无问题:项目列表用 ProjectListSerializer+annotate 无 N+1;详情/商品/AITask/资产列表均已 select_related/prefetch_related + defer 大 JSON;billing/team_members 已批量化。
|
||||
|
||||
---
|
||||
|
||||
## 五、后端 · 端点耗时与 payload(7/10)
|
||||
|
||||
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | `settings/production.py`(缺 `CONN_MAX_AGE`)vs `development.py:10-13` | **线上 MySQL 连接未复用**,每请求新建连接 ~1.6s 握手。开发环境设了 300s 复用并注明"登录并发 12 接口=卡几十秒",**这条优化只在 development 生效** | 线上每接口多 1.6s,一进页面并发 N 请求=N×握手 | production.py 加 `CONN_MAX_AGE=300`+`CONN_HEALTH_CHECKS=True`(提到 base.py 更稳) |
|
||||
| **P0** | `App.tsx:324,331`+`pipeline.tsx:1876`→`projects/views.py:118` | 任一视频段 running 时**每 5s 调 `api.project`**,触发 12-prefetch 最重序列化器,把全部版本/分镜/资产/timeline 一次塞响应 | 视频阶段持续拉巨 payload + 整页重渲 | 轮询改用已有轻量 `poll-video-segment`(views.py:546)只回单段状态;或详情加 ETag→304 |
|
||||
| P1 | `projects/views.py:278`+`assets/review.py:111`,前端 `pipeline.tsx:816`(8s) | `poll-reviews` 在**请求线程里同步循环调火山外部 HTTP**(每个审核中资产一次),前端每 8s 打一次 | N 个审核资产时单次请求阻塞=N×火山往返 | 火山审核轮询挪 Celery 后台,端点只读 DB review_status |
|
||||
| P1 | `apps/ai/views.py:68` `AITaskViewSet` | 未设显式 `ordering`,分页可能重/漏行 | 任务多时前端反复拉 | 加 `ordering=["-created_at"]` |
|
||||
| P2 | `apps/ops/views.py:276` list | 每次额外 6 条 count(同上 N+1 P1) | 无高频轮询,影响有限 | 合并 count |
|
||||
| P2 | `apps/billing/views.py:182` trend | "按阶段分布"用 Python 循环累加本月全部流水,非 DB 聚合 | 流水多时账户页加载慢 | 改 `values('task__task_type').annotate(Sum)` |
|
||||
|
||||
✅ 已核实无问题:全局分页健全(PAGE_SIZE=20,无全量返回);assets/AITask/billing 已 defer 3MB+ JSON 大列(注释"40 条要 30s+"已修);poll-storyboard 后台线程不阻塞;通知生成已 Celery+60s 缓存。
|
||||
|
||||
---
|
||||
|
||||
## 六、后端 · 数据库索引(7/10)
|
||||
|
||||
> 基表 `team` FK 自带单列索引,纯 `filter(team=)` 不慢。真正缺的全是"team + 第二过滤列/时间排序"的**复合索引**。远程 MySQL 上数据量上来后,缺索引的过滤/排序会明显变慢。
|
||||
|
||||
| 严重度 | 表/字段 | 问题 | 体感影响 | 加什么索引 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | **Asset** `(team,category,-created_at)` 等 | `assets/models.py:6` 整表**零自定义索引**,资产库每 tab `filter(team,category).order_by(-created_at)` + 6 个 count + facets distinct。Asset 是全站最大增长表 | 资产库刷新/切 tab/徽标计数,大表全表扫+filesort 秒级卡 | `Index(["team","category","-created_at"])` + `(team,asset_type)` + 视需要 `(team,source)` |
|
||||
| **P0** | **Asset** `(team,-created_at)` | 默认无筛选排序也要 team 内取全部再 filesort | 默认视图随资产数线性变慢 | `Index(["team","-created_at"])`(或被上条复合覆盖) |
|
||||
| P1 | **CreditLedger** `(team,-created_at)` | 流水分页 `filter(team).order_by(-created_at)`,现有索引不覆盖。Ledger 高频写入 | 账单流水翻页随增长变慢 | `Index(["team","-created_at"])` |
|
||||
| P1 | **CreditLedger** `created_at` 范围 | 消费趋势 `filter(team,ledger_type=CHARGE,created_at__date__gte)`+TruncDate 分组无法走索引 | 账户消费分析月度数据多时慢 | `(team,ledger_type)` 扩成 `(team,ledger_type,created_at)` |
|
||||
| P1 | **AITask** `(team,-created_at)` | 无显式 ordering + 无时间序索引 | 任务列表分页不稳+filesort | `Index(["team","-created_at"])` + ViewSet `ordering` |
|
||||
| P1 | **Project** `(team,-updated_at)` | 列表 `filter(team).order_by(-updated_at)`,索引不含 updated_at | 项目列表/侧栏随项目数变慢 | `Index(["team","-updated_at"])` |
|
||||
| P1 | **Product** `(team,-created_at)` | 列表无显式 ordering + 时间序索引 | 商品列表分页随商品数变慢 | `Index(["team","-created_at"])` + 默认 ordering |
|
||||
| P2 | **Asset** `metadata__product_id` | 按商品过滤走 JSON 提取 + 三路 Q 并集 distinct,无法走普通索引 | 商品详情"该商品资产"大表下慢 | MySQL 函数/生成列索引,或冗余真实 `product_id` 列 + `(team,product_id)` |
|
||||
|
||||
> ⚠️ 远程 MySQL 大表 `ALTER TABLE ADD INDEX` 会锁表,建议低峰执行或用 online DDL。Notification 表(`ops/models.py:52`)索引已做得很好,可作其它表范本。
|
||||
|
||||
---
|
||||
|
||||
## 七、已修复
|
||||
|
||||
- **登录假动画延迟(🔴2)** —— [auth-screen.tsx](core/frontend/src/routes/auth-screen.tsx) 已删除 `api.login()` 成功后的 verified/entering 两段共 ~1.5s 人为 `wait`,以及 checking 阶段的最小垫时。现在凭证验过即进,"进入"态由真实的 `onAuthed`(身份+数据水合)驱动,不再演动画。
|
||||
|
||||
---
|
||||
|
||||
## 八、建议执行顺序(按 ROI)
|
||||
|
||||
### 第一波 · 一行/小改,立竿见影(强烈建议先做)
|
||||
1. **🔴1 production.py 加 `CONN_MAX_AGE`** —— 消掉线上每请求 1.6s 握手,"刷新慢"最大单点
|
||||
2. **🔴3 视频 5s 轮询改轻量端点** —— 别再每 5s 拉全量项目详情
|
||||
3. **🔴8 两处 `<img>` 补 `loading="lazy"`** + 商品详情/图片工具改服务端分页
|
||||
4. **隐藏 tab 暂停轮询**(poll 加 document.hidden 守卫)
|
||||
|
||||
### 第二波 · 后端查询治理
|
||||
5. **🔴4 消息中心首屏 N+1** —— 批量聚合 + bulk_create + Celery 异步
|
||||
6. **🔴5 Asset/Ledger/Project/AITask 加复合索引**(低峰执行 DDL)
|
||||
7. **poll-reviews 火山同步 HTTP 挪 Celery**
|
||||
8. **写 action 不再回吐全量 ProjectSerializer**
|
||||
|
||||
### 第三波 · 前端体验
|
||||
9. **🔴6 pipeline 巨页拆 memo 子组件**(工作量大,但根治 pipeline 不丝滑)
|
||||
10. **🟠7 切走再回加内存缓存 + SWR**
|
||||
11. **action() 普遍补 liteRefresh + 条件化 refreshProjectDetail**
|
||||
|
||||
---
|
||||
|
||||
## 九、客观说明
|
||||
|
||||
- **张业昌确实优化过一波且方向对**:骨架先出、并行水合、defer 大 JSON 列、列表轻量序列化器、prefetch、防竞态、去抖——这些都到位,不是没做。
|
||||
- **剩下的卡顿高度集中**:① 一条线上配置漏搬(CONN_MAX_AGE)② 视频轮询拉全量 ③ 消息中心首屏 N+1 ④ Asset 缺索引 ⑤ pipeline 巨页。**修掉前 4 个(都不算大改),"刷新慢"基本能解决**;pipeline 巨页拆分是更大的工程,可单独排期。
|
||||
- 本报告每条都带 file:line,可直接当修复 checklist 交给张业昌认领。
|
||||
Reference in New Issue
Block a user