diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 2b94117..db8ccd3 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -45,13 +45,15 @@ class AssetFileSerializer(serializers.ModelSerializer): ] def get_preview_url(self, obj): - # 存储字段优先(如外部已写入绝对 URL);否则用 object_key 实时签发 TOS 预签名 GET URL + # 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。 + # TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL + # (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。 if obj.preview_url: return obj.preview_url if not obj.object_key or not settings.TOS.get("endpoint"): return "" try: - return _tos().presigned_get_url(object_key=obj.object_key) + return _tos().public_url(object_key=obj.object_key, bucket=obj.bucket or None) except Exception: return "" diff --git a/core/backend/apps/assets/storage.py b/core/backend/apps/assets/storage.py index 085fe5e..f3351e2 100644 --- a/core/backend/apps/assets/storage.py +++ b/core/backend/apps/assets/storage.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from typing import BinaryIO +from urllib.parse import quote, urlparse import boto3 from botocore.config import Config @@ -50,3 +51,10 @@ class TosStorage: Params={"Bucket": self.bucket, "Key": object_key}, ExpiresIn=expires_in, ) + + def public_url(self, *, object_key: str, bucket: str | None = None) -> str: + """桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。 + 相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存 + (签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。""" + host = urlparse(settings.TOS["endpoint"]).netloc + return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}" diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1ec9a78..6119723 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -110,6 +110,7 @@ export function App() { const [ledgers, setLedgers] = useState([]); const [billingTrend, setBillingTrend] = useState(null); const [notifications, setNotifications] = useState([]); + const [notificationTotal, setNotificationTotal] = useState(0); // 后端真实总数(团队动态「共 N」用,与已加载的近期 100 条区分) const [unreadCount, setUnreadCount] = useState(0); const [projectDetail, setProjectDetail] = useState(null); const [exportResult, setExportResult] = useState(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) => diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 656c8b2..bb2a4d0 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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("/api/assets/upload/", { method: "POST", body: formData }); }, + // 流程步骤4 · 给人物/资产改名(添加人物工作台命名 → PATCH 写回 name) + updateAsset(id: string, payload: { name?: string; description?: string }) { + return request(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, billingSummary() { return request("/api/billing/summary/"); }, @@ -437,25 +442,11 @@ export const api = { const qs = query.toString(); return request(`/api/ops/notifications/${qs ? `?${qs}` : ""}`); }, - // 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路 + // 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够, + // count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。 + // 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。 async allNotifications(): Promise { - // 首页拿计数,剩余页并行(原串行逐页:消息多时侧边栏徽标加载也拖慢整页) - const SIZE = 100; - const first = await request(`/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(`/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(`/api/ops/notifications/?page_size=100`); }, markAllNotificationsRead() { return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", { diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index e730844..c80f646 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -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; onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise; @@ -211,7 +212,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav

团队动态

- // 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notifications.length} + // 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length} navigate("messages")}>全部 →
diff --git a/core/qa/function-audit/perf-probe.mjs b/core/qa/function-audit/perf-probe.mjs index a686d08..763963c 100644 --- a/core/qa/function-audit/perf-probe.mjs +++ b/core/qa/function-audit/perf-probe.mjs @@ -175,8 +175,10 @@ async function probePage(context, name, route, ids) { if (onLogin > 0 && hasApp === 0) problems.push("掉回登录页/未渲染(功能崩或登录态失效)"); // 没等到 bootstrap 完成 = 后端太慢拖到超时。这不是「请求少=快」,而是「慢到没加载出来」,判失败防假通过。 if (!bootstrapStarted) problems.push(`bootstrap 未完成(${HARD_CAP}ms 内 /api/products/ 未发出,后端过慢)`); - if (waterfall.length > 0) problems.push(`资产分页瀑布 ${waterfall.length} 页(page_size 没生效→并行翻页)`); if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`); + // 资产翻页本身:page_size 是否生效已由 API 级硬闸确定性守住。这里翻 1~2 页可能是「资产真超 200」的 + // 合法分页,故只在「明显过量(≥3 页 = page_size 没生效的老症状)」时提示,不硬挂闸(避免误报)。 + if (waterfall.length >= 3) warnings.push(`资产分页瀑布 ${waterfall.length} 页(疑似 page_size 失效,查 API 硬闸)`); if (unique > budget) warnings.push(`首屏接口数 ${unique} > 预算 ${budget}(架构性过量拉取,待决策)`); return { name, route: url, unique, budget, rawRequests: reqs.length, waterfall: waterfall.length, duplicates, failed: failedReqs, breakdown, elapsedMs: elapsed, problems, warnings, pass: problems.length === 0 };