perf(core): 资产预览改 TOS 公读直链 + 通知只取首页,减刷新开销与缓存失效

- 资产 preview_url 从「逐图签发预签名 URL」改为虚拟主机式公读直链
  (https://{bucket}.{host}/{key})。桶公读已验证免签可访问;直链稳定可被浏览器/CDN
  缓存,而签名链每次都变、1h 过期反而打不到缓存。boto3 也移出序列化热路径。
- allNotifications 不再逐页翻全部(原 O(N) 随消息增长拖慢每次刷新),只取首页 100 条
  (侧边栏徽标 unread_count + 团队动态仅展示最近 6 条已足够);「共 N」改用后端真实 count。
- perf-probe:page_size 由 API 级硬闸确定性守住后,浏览器端「资产翻页」降为提示
  (翻 1~2 页是资产真超 200 的合法分页,仅 ≥3 页才疑似 page_size 失效)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 16:58:48 +08:00
co-authored by Claude Opus 4.8
parent 1ff7be2b56
commit 8ba76e5bb7
6 changed files with 37 additions and 26 deletions
+9 -2
View File
@@ -110,6 +110,7 @@ export function App() {
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notificationTotal, setNotificationTotal] = useState(0); // 后端真实总数(团队动态「共 N」用,与已加载的近期 100 条区分)
const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
@@ -162,6 +163,7 @@ export function App() {
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
setNotificationTotal(notificationData.count);
setUnreadCount(notificationData.unread_count);
}
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
@@ -214,6 +216,7 @@ export function App() {
const data = await api.allNotifications().catch(() => null);
if (data) {
setNotifications(data.results);
setNotificationTotal(data.count);
setUnreadCount(data.unread_count);
}
}, []);
@@ -674,6 +677,7 @@ export function App() {
members={teamMembers}
billing={billing}
notifications={notifications}
notificationTotal={notificationTotal}
navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
@@ -771,7 +775,7 @@ export function App() {
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
@@ -784,8 +788,11 @@ export function App() {
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "person");
return action(() => api.uploadAsset(fd), "演员已保存到演员库");
// 返回上传后的 Asset(供添加人物工作台进右侧栏做三视图/命名)
return action(() => api.uploadAsset(fd), "");
}}
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
onRenameActor={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onSubmitAllVideos={(prompt) =>
+10 -19
View File
@@ -312,7 +312,8 @@ export const api = {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
attachBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset)
@@ -401,6 +402,10 @@ export const api = {
uploadAsset(formData: FormData) {
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
},
// 流程步骤4 · 给人物/资产改名(添加人物工作台命名 → PATCH 写回 name)
updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
@@ -437,25 +442,11 @@ export const api = {
const qs = query.toString();
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
},
// 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路
// 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够,
// count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。
// 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。
async allNotifications(): Promise<NotificationList> {
// 首页拿计数,剩余页并行(原串行逐页:消息多时侧边栏徽标加载也拖慢整页)
const SIZE = 100;
const first = await request<NotificationList>(`/api/ops/notifications/?page_size=${SIZE}`);
const out: Notification[] = [...first.results];
const pageSize = first.results.length || SIZE;
const totalPages = pageSize > 0 ? Math.ceil((first.count || out.length) / pageSize) : 1;
if (totalPages > 1) {
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
request<NotificationList>(`/api/ops/notifications/?page_size=${SIZE}&page=${i + 2}`)
.then((p) => p.results)
.catch(() => [] as Notification[])
)
);
rest.forEach((r) => out.push(...r));
}
return { count: out.length, next: null, previous: null, results: out, unread_count: first.unread_count, type_counts: first.type_counts };
return request<NotificationList>(`/api/ops/notifications/?page_size=100`);
},
markAllNotificationsRead() {
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
+3 -2
View File
@@ -27,12 +27,13 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
export function TeamPage({ team, user, members, billing, notifications = [], navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
export function TeamPage({ team, user, members, billing, notifications = [], notificationTotal, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
team: Team;
user: User;
members: TeamMember[];
billing: BillingSummary | null;
notifications?: Notification[];
notificationTotal?: number; // 后端真实总数(只加载了近期 100 条,「共 N」用这个而非数组长度)
navigate: (page: Page) => void;
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
@@ -211,7 +212,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
<div className="team-feed">
<div className="h">
<h3></h3>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notifications.length}</span>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}> </a>
</div>
<div className="feed-list">