-
- {task.id.slice(0, 4)}
+
+ {img ?  : {task.id.slice(0, 4)}}
{typeLabel}
@@ -337,6 +365,14 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
)}
+
+ {hasMore && (
+
+
+
+ )}
);
}
@@ -451,6 +487,8 @@ export function ImageWorkbenchPage({
const [results, setResults] = useState (null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef(null);
+ // 生成结果图片放大预览
+ const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
@@ -502,6 +540,8 @@ export function ImageWorkbenchPage({
function renderResultGrid() {
const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2;
return (
+ <>
+ setPreview(null)} />
(
{url ? (
- 
+  setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : (
@@ -538,6 +578,7 @@ export function ImageWorkbenchPage({
))}
+ >
);
}
diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx
index 5799b8b..69b75e0 100644
--- a/core/frontend/src/routes/library.tsx
+++ b/core/frontend/src/routes/library.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Asset } from "../types";
-import { ConfirmModal, Drawer } from "../components/overlays";
+import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
const SOURCE_LABELS: Record = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
@@ -52,6 +52,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const [editMode, setEditMode] = useState(false);
const [metaFilter, setMetaFilter] = useState>({});
const [confirmId, setConfirmId] = useState(null);
+ // 资产预览灯箱(图片放大 / 视频播放)
+ const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
@@ -213,6 +215,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
{filtered.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
+ const isVideo = asset.asset_type === "video";
+ const openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined;
return (
{editMode && onDelete && (
@@ -220,8 +224,15 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
)}
-
- {cover ?  : {asset.asset_type}}
+ { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
+ {cover
+ ? (isVideo
+ ? <>
+
+
+ >
+ :  )
+ : {asset.asset_type}}
{asset.name} {asset.category} · {asset.source}
@@ -232,6 +243,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
// 当前分类暂无真实资产
)}
+ setPreview(null)} />
+
(["task", "team", "billing", "system"]);
+
+// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
+function tabParams(tab: TabKey): { type?: string; unread?: boolean } {
+ if (tab === "unread") return { unread: true };
+ if (TYPE_TABS.has(tab)) return { type: tab };
+ return {};
+}
+
const PRI_LABEL: Record = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
const ZH_TYPE: Record = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
@@ -41,8 +53,7 @@ function fmtFull(iso: string): string {
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
}
-export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
- notifications: Notification[];
+export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: {
unreadCount: number;
onMarkRead: (id: string) => void | Promise;
onMarkAllRead: () => void | Promise;
@@ -50,35 +61,92 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
}) {
const [tab, setTab] = useState("all");
const [query, setQuery] = useState("");
+ const [debounced, setDebounced] = useState("");
const [selectedId, setSelectedId] = useState("");
- const counts = useMemo(
- () => ({
- all: notifications.length,
- unread: notifications.filter((n) => !n.is_read).length,
- task: notifications.filter((n) => n.notification_type === "task").length,
- team: notifications.filter((n) => n.notification_type === "team").length,
- billing: notifications.filter((n) => n.notification_type === "billing").length,
- system: notifications.filter((n) => n.notification_type === "system").length
- }),
- [notifications]
+ const [items, setItems] = useState([]);
+ const [counts, setCounts] = useState(() => ({ ...ZERO_COUNTS, unread: unreadCount }));
+ const [total, setTotal] = useState(0); // 当前筛选(tab/搜索)下的总条数,作「已加载 X / Y」的分母
+ const [page, setPage] = useState(1); // 下一个要拉的页码
+ const [hasMore, setHasMore] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const loadingRef = useRef(false); // 防滚动重复触发追加
+ const genRef = useRef(0); // 代号:tab/搜索一变就 +1,丢弃旧请求的回包
+ const listRef = useRef(null);
+
+ // 搜索去抖 300ms 再打服务端
+ useEffect(() => {
+ const t = setTimeout(() => setDebounced(query.trim()), 300);
+ return () => clearTimeout(t);
+ }, [query]);
+
+ const load = useCallback(
+ async (pageToLoad: number, replace: boolean) => {
+ // 追加(滚动)要防并发;重拉(replace)不阻塞,靠代号作废在途旧请求
+ if (!replace && loadingRef.current) return;
+ const gen = replace ? (genRef.current += 1) : genRef.current;
+ loadingRef.current = true;
+ setLoading(true);
+ try {
+ const res = await api
+ .listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
+ .catch(() => null);
+ if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
+ if (!res) return;
+ setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
+ setHasMore(Boolean(res.next));
+ setPage(pageToLoad + 1);
+ setTotal(res.count);
+ if (res.type_counts) setCounts(res.type_counts);
+ } finally {
+ if (gen === genRef.current) {
+ loadingRef.current = false;
+ setLoading(false);
+ }
+ }
+ },
+ [tab, debounced]
);
- const visible = useMemo(() => {
- const q = query.trim().toLowerCase();
- return notifications.filter((n) => {
- if (tab === "unread" && n.is_read) return false;
- if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
- if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
- return true;
- });
- }, [notifications, tab, query]);
+ // tab / 搜索变化 → 清空重拉第 1 页
+ useEffect(() => {
+ setItems([]);
+ setSelectedId("");
+ setHasMore(false);
+ void load(1, true);
+ }, [load]);
- const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
+ // 滚到接近底部就拉下一批
+ const onScroll = useCallback(() => {
+ const el = listRef.current;
+ if (!el || !hasMore || loadingRef.current) return;
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) void load(page, false);
+ }, [hasMore, page, load]);
+
+ // 首批撑不满面板(没出现滚动条)却还有更多 → 自动续拉,保证可触达
+ useEffect(() => {
+ const el = listRef.current;
+ if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false);
+ }, [items, hasMore, page, load]);
+
+ const selected = items.find((n) => n.id === selectedId) || items[0] || null;
+
+ // 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
+ function markOne(id: string) {
+ void onMarkRead(id);
+ setItems((prev) => prev.map((x) => (x.id === id ? { ...x, is_read: true, unread: false } : x)));
+ setCounts((c) => ({ ...c, unread: Math.max(0, c.unread - 1) }));
+ }
function selectItem(n: Notification) {
setSelectedId(n.id);
- if (!n.is_read) void onMarkRead(n.id);
+ if (!n.is_read) markOne(n.id);
+ }
+
+ async function markAll() {
+ await onMarkAllRead();
+ setItems((prev) => prev.map((x) => ({ ...x, is_read: true, unread: false })));
+ setCounts((c) => ({ ...c, unread: 0 }));
}
const filters: Array<[TabKey, string, number]> = [
@@ -97,17 +165,17 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
消息中心
- // {counts.unread} 条未读 · {notifications.length} 条总计 任务提醒 · 团队协作 · 计费与系统公告
+ // {counts.unread} 条未读 · {counts.all} 条总计 任务提醒 · 团队协作 · 计费与系统公告
-
+
- 收件箱// 显示 {visible.length} 条
+ 收件箱// 已加载 {items.length} / {total} 条
{filters.map(([id, label, ct]) => (
-
- {visible.length === 0 ? (
+
+ {items.length === 0 && !loading ? (
没有符合条件的消息
) : (
- visible.map((n) => (
-
- ))
+
+ ))}
+ {(loading || hasMore) && {loading ? "// 加载中…" : "// 滚动加载更多"} }
+ {!loading && !hasMore && items.length > 0 && // 已全部加载 }
+ >
)}
@@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
- {!selected.is_read && void onMarkRead(selected.id)}>标为已读}
+ {!selected.is_read && markOne(selected.id)}>标为已读}
navigate(target)}>进入{routeLabels[target]}
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx
index 4ec0173..24c99f8 100644
--- a/core/frontend/src/routes/pipeline.tsx
+++ b/core/frontend/src/routes/pipeline.tsx
@@ -5,6 +5,7 @@ import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, Timelin
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
+import { MediaLightbox } from "../components/overlays";
import { IconKitSvg } from "../components/IconKitSvg";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
@@ -158,6 +159,8 @@ export function PipelinePage(props: {
const activeDot = navigated ? viewStage : projectStage;
const completed = Math.max(projectStage - 1, activeDot - 1);
const [chatText, setChatText] = useState("");
+ // 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
+ const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
const [chatAttachments, setChatAttachments] = useState>([]);
const chatTextareaRef = useRef(null);
@@ -796,7 +799,7 @@ export function PipelinePage(props: {
{(() => {
const url = frameUrl(sbActiveFrame);
return (
-
+ setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}>
{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}
);
@@ -899,7 +902,7 @@ export function PipelinePage(props: {
const busy = ["running", "queued"].includes(seg.status);
return (
-
+ setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }); } } : undefined}>
{url
?
: 场 {seg.sort_order + 1}}
@@ -1199,6 +1202,7 @@ export function PipelinePage(props: {
})()}
+ setPreview(null)} />
);
}
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx
index b47f20e..24eeb6d 100644
--- a/core/frontend/src/routes/products.tsx
+++ b/core/frontend/src/routes/products.tsx
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
+import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
-import { ConfirmModal } from "../components/overlays";
+import { ConfirmModal, MediaLightbox } from "../components/overlays";
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import "../product-create-page.css";
@@ -74,6 +75,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
const [imagePreview, setImagePreview] = useState ("");
const imgInputRef = useRef(null);
const [showGuide, setShowGuide] = useState(false);
+ const [titleError, setTitleError] = useState(false);
function pickImage(event: ChangeEvent) {
const file = event.target.files?.[0];
if (file) setImagePreview(URL.createObjectURL(file));
@@ -131,8 +133,17 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
setBullets((list) => list.filter((_, position) => position !== index));
}
+ function resetForm() {
+ setTitle("");
+ setCategory("");
+ setTarget("");
+ setBullets([]);
+ setBulletDraft("");
+ setImagePreview("");
+ setTitleError(false);
+ }
function submit() {
- if (!title.trim()) return;
+ if (!title.trim()) { setTitleError(true); return; } // 空名:给必填校验提示(不再静默无反应)
onCreate({
title: title.trim(),
category: category || PC_CAT_OPTIONS[0],
@@ -140,11 +151,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
setDrawer(false);
- setTitle("");
- setCategory("");
- setTarget("");
- setBullets([]);
- setBulletDraft("");
+ resetForm();
}
return (
@@ -159,7 +166,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
{editMode ? "完成" : "管理商品"}
- setDrawer(true)}>
+ { resetForm(); setDrawer(true); }}>
新建商品
@@ -249,7 +256,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
onConfirm={doDelete}
/>
- {/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */}
+ {/* 新建商品 · 右侧 Drawer · portal 到 body,脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏(转写自 products.html #pc-drawer) */}
+ {createPortal(
+ <>
setDrawer(false)} />
|