feat(core): notification inbox infinite scroll + command palette fix (+ pending WIP)

消息中心:全量渲染 → 真·后端分页滚动加载
- backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+
  响应回 type_counts(按收件人绝对计数,不受分页/搜索影响)
- frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页;
  代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数
- api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts

命令面板(侧边栏搜索):修复点开后 UI 错位
- app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下;
  补回基类 + header 类名对齐 .shell-command-h
- messages-page.css: 工作台收进视口高度,收件箱在面板内滚动

本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings +
accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-10 09:37:41 +08:00
co-authored by Claude Opus 4.8
parent aa4bdeac83
commit 3fac38c5ef
29 changed files with 724 additions and 150 deletions
+75 -9
View File
@@ -3,6 +3,9 @@ import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
@@ -12,6 +15,33 @@ const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: st
type Tab = "overview" | "by-project" | "by-member" | "bills";
// 账单类型 / 详情 中文化(后端历史英文流水也一并映射;未知值原样透出)
const LEDGER_TYPE_LABEL: Record<string, string> = {
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调整", refund: "退款"
};
const LEDGER_REASON_LABEL: Record<string, string> = {
"reserve ai task credit": "AI 任务预扣额度",
"charge ai task credit": "AI 任务扣费",
"release reserved credit": "释放预留额度",
"release unused reserved credit": "释放未用预留额度",
"release unused credit": "释放未用额度"
};
const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开)
function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const items: Array<number | "ellipsis"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("ellipsis");
for (let p = start; p <= end; p++) items.push(p);
if (end < total - 1) items.push("ellipsis");
items.push(total);
return items;
}
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
@@ -38,6 +68,23 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
const [recharge, setRecharge] = useState(500);
const [customAmt, setCustomAmt] = useState("");
// 账单流水分页:服务端分页(总数随流水增长,不再写死 100),每页 10 条
const BILLS_PER_PAGE = 10;
const [billPage, setBillPage] = useState(1);
const [ledgerRows, setLedgerRows] = useState<Ledger[]>(ledgers);
const [ledgerCount, setLedgerCount] = useState<number>(ledgers.length);
useEffect(() => {
let alive = true;
api.ledgers(billPage, BILLS_PER_PAGE).then((data) => {
if (!alive) return;
setLedgerRows(data.results);
setLedgerCount(data.count);
}).catch(() => {});
return () => { alive = false; };
}, [billPage]);
const billTotalPages = Math.max(1, Math.ceil(ledgerCount / BILLS_PER_PAGE));
const safeBillPage = Math.min(billPage, billTotalPages);
const selectedCard = RECHARGE.find((item) => item.amt === recharge);
const effectiveAmount = Number(customAmt) > 0 ? Number(customAmt) : recharge;
const effectiveBonus = Number(customAmt) > 0 ? 0 : selectedCard?.bonusAmt || 0;
@@ -158,7 +205,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}>总览</button>
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}>项目 <span className="count">{projects.length}</span></button>
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}>成员 <span className="count">{teamMembers.length}</span></button>
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgers.length}</span></button>
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}>账单流水 <span className="count">{ledgerCount}</span></button>
</div>
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
@@ -238,18 +285,34 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
<table className="billing-table">
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
<tbody>
{ledgers.map((l) => (
{ledgerRows.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{l.ledger_type}</td>
<td className="muted">{l.reason}</td>
<td></td>
<td><span className="status-tag ok">OK</span></td>
<td>{ledgerTypeLabel(l.ledger_type)}</td>
<td className="muted">{ledgerReasonLabel(l.reason)}</td>
<td>{l.user_label
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
: <span className="sys">系统</span>}</td>
<td><span className="status-tag ok">成功</span></td>
<td className="neg">{l.amount}</td>
</tr>
))}
</tbody>
</table>
{ledgerCount > BILLS_PER_PAGE && (
<div className="bill-pager">
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
<div className="pages">
<button type="button" disabled={safeBillPage <= 1} onClick={() => setBillPage(safeBillPage - 1)}>上一页</button>
{pageWindow(safeBillPage, billTotalPages).map((p, i) => (
p === "ellipsis"
? <span key={`e${i}`} className="ellipsis">…</span>
: <button key={p} className={p === safeBillPage ? "active" : ""} type="button" onClick={() => setBillPage(p)}>{p}</button>
))}
<button type="button" disabled={safeBillPage >= billTotalPages} onClick={() => setBillPage(safeBillPage + 1)}>下一页</button>
</div>
</div>
)}
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
@@ -270,9 +333,12 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
<table className="billing-table">
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
<tbody>
{teamMembers.map((m) => (
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{m.role}</td><td className="zero">{money(m.monthly_credit_limit)}</td><td>{m.status}</td></tr>
))}
{teamMembers.map((m) => {
const monthly = Number(m.monthly_credit_limit || 0);
return (
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{ROLE_LABEL[m.role] || m.role}</td><td className="quota"><span className="used">{money(m.month_charged || 0)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}</span></td><td>{STATUS_LABEL[m.status] || m.status}</td></tr>
);
})}
</tbody>
</table>
</div>
+51 -10
View File
@@ -22,6 +22,7 @@ import {
X
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import { MediaLightbox } from "../components/overlays";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
@@ -46,7 +47,14 @@ const STATUS_LABEL: Record<string, string> = {
running: "生成中",
queued: "排队中",
polling: "生成中",
needs_review: "待确认"
needs_review: "待确认",
// 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文)
created: "待处理",
reserved: "排队中",
submitted: "已提交",
postprocessing: "处理中",
compensating: "回滚中",
cancelled: "已取消"
};
function statusText(status: string) {
@@ -71,7 +79,18 @@ async function downloadImage(url: string, filename: string) {
}
}
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate: (page: Page) => void; aiTasks: AITask[]; assets?: Asset[] }) {
// 任务 → 生成结果图:按 asset.origin_task 关联,取首张有预览 URL 的图片文件(脚本/视频任务无图则留占位)
const taskImage = useMemo(() => {
const map: Record<string, string> = {};
for (const asset of assets) {
const taskId = asset.origin_task;
if (!taskId || map[taskId]) continue;
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
if (file?.preview_url) map[taskId] = file.preview_url;
}
return map;
}, [assets]);
const cards = [
{
page: "modelPhoto" as Page,
@@ -112,8 +131,11 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("list");
const [view, setView] = useState<"grid" | "list">("grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
const TASKS_PER_LOAD = 12;
const [shown, setShown] = useState(TASKS_PER_LOAD);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
@@ -141,6 +163,10 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
}
return true;
});
// 筛选条件变化时回到第一屏(12 条)
useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]);
const paged = visible.slice(0, shown);
const hasMore = visible.length > paged.length;
return (
<div className="asset-factory">
@@ -246,7 +272,7 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</div>
<div className="result-meta">
// 显示 {visible.length} / {aiTasks.length} 个任务
// 显示 {paged.length} / {visible.length} 个任务
</div>
{aiTasks.length === 0 ? (
@@ -261,12 +287,13 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</div>
) : view === "grid" ? (
<div className="history-grid">
{visible.map((task) => {
{paged.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
return (
<article className="task-card history-card" key={task.id}>
<div className="placeholder"><span className="ph-frame">{task.id.slice(0, 4)}</span></div>
<div className={`placeholder${img ? " has-img" : ""}`}>{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}</div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
@@ -292,15 +319,16 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</tr>
</thead>
<tbody>
{visible.map((task) => {
{paged.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
return (
<tr key={task.id}>
<td>
<div className="task-name-cell">
<div className="placeholder task-thumb">
<span className="ph-frame">{task.id.slice(0, 4)}</span>
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
</div>
<div>
<div className="task-name">{typeLabel}</div>
@@ -337,6 +365,14 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</table>
</div>
)}
{hasMore && (
<div className="task-load-more">
<button className="btn" type="button" onClick={() => setShown((n) => n + TASKS_PER_LOAD)}>
加载更多 <span className="lm-rest">还有 {visible.length - paged.length} 个</span>
</button>
</div>
)}
</div>
);
}
@@ -451,6 +487,8 @@ export function ImageWorkbenchPage({
const [results, setResults] = useState<Asset[] | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef<HTMLInputElement | null>(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 (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
<div
className="gen-images"
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
@@ -512,7 +552,7 @@ export function ImageWorkbenchPage({
).map(({ key, index, url }) => (
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
{url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" />
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : (
<div className="placeholder">
<span className="ph-frame">
@@ -538,6 +578,7 @@ export function ImageWorkbenchPage({
</div>
))}
</div>
</>
);
}
+16 -3
View File
@@ -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<string, string> = { 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<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(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
<div className="asset-grid" id="asset-grid">
{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 (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
{editMode && onDelete && (
@@ -220,8 +224,15 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb">
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
{cover
? (isVideo
? <>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
: <img src={cover} alt={asset.name} loading="lazy" />)
: <span className="ph-frame">{asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
</article>
@@ -232,6 +243,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
<ConfirmModal
open={Boolean(confirmId)}
title="删除资产"
+119 -47
View File
@@ -1,11 +1,23 @@
import { useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
import type { Notification } from "../types";
import { api } from "../api";
import type { Notification, NotificationTypeCounts } from "../types";
import type { Page } from "./route-config";
import { routeLabels } from "./route-config";
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
const PAGE_SIZE = 10; // 每次滚到底加载一批
const ZERO_COUNTS: NotificationTypeCounts = { all: 0, unread: 0, task: 0, team: 0, billing: 0, system: 0 };
const TYPE_TABS = new Set<TabKey>(["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<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
const ZH_TYPE: Record<string, string> = { 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<unknown>;
onMarkAllRead: () => void | Promise<unknown>;
@@ -50,35 +61,92 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
}) {
const [tab, setTab] = useState<TabKey>("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<Notification[]>([]);
const [counts, setCounts] = useState<NotificationTypeCounts>(() => ({ ...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<HTMLDivElement>(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
<div className="page-head">
<div>
<h1>消息中心</h1>
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
</div>
<div className="msg-head-actions">
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部标已读</button>
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>全部标已读</button>
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
</div>
</div>
<div className="msg-workbench">
<section className="msg-panel msg-inbox">
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 已加载 {items.length} / {total} 条</span></div>
<div className="msg-filters">
{filters.map(([id, label, ct]) => (
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
@@ -119,27 +187,31 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
<Search />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
</div>
<div className="msg-list">
{visible.length === 0 ? (
<div className="msg-list" ref={listRef} onScroll={onScroll}>
{items.length === 0 && !loading ? (
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
) : (
visible.map((n) => (
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
<span className="msg-item-main">
<span className="msg-item-row">
<span className="msg-dot"></span>
<span className="msg-item-title">{n.title}</span>
<span className="msg-time">{fmtTime(n.created_at)}</span>
<>
{items.map((n) => (
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
<span className="msg-item-main">
<span className="msg-item-row">
<span className="msg-dot"></span>
<span className="msg-item-title">{n.title}</span>
<span className="msg-time">{fmtTime(n.created_at)}</span>
</span>
<span className="msg-brief">{n.brief}</span>
<span className="msg-item-foot">
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
</span>
</span>
<span className="msg-brief">{n.brief}</span>
<span className="msg-item-foot">
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
</span>
</span>
</button>
))
</button>
))}
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
</>
)}
</div>
</section>
@@ -175,7 +247,7 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
</div>
</div>
<div className="msg-detail-f">
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
<span className="spacer"></span>
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
</div>
+6 -2
View File
@@ -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<Array<{ name: string; chars: number }>>([]);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -796,7 +799,7 @@ export function PipelinePage(props: {
{(() => {
const url = frameUrl(sbActiveFrame);
return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => 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}>
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
</div>
);
@@ -899,7 +902,7 @@ export function PipelinePage(props: {
const busy = ["running", "queued"].includes(seg.status);
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }}>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击播放" : undefined} onClick={url ? () => 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
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
: <span className="ph-frame">场 {seg.sort_order + 1}</span>}
@@ -1199,6 +1202,7 @@ export function PipelinePage(props: {
})()}
</div>
</main>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}
+41 -18
View File
@@ -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<string>("");
const imgInputRef = useRef<HTMLInputElement | null>(null);
const [showGuide, setShowGuide] = useState(false);
const [titleError, setTitleError] = useState(false);
function pickImage(event: ChangeEvent<HTMLInputElement>) {
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
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
</button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => { resetForm(); setDrawer(true); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
新建商品
</button>
@@ -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(
<>
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
<div className="drawer-h">
@@ -263,7 +272,8 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
<div className="form-card">
<div className="field">
<label className="field-label">商品名称<span className="req">*</span></label>
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="请输入商品名称(必填)" maxLength={100} />
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}>请先填写商品名称</div>}
</div>
<div className="field-row">
@@ -299,9 +309,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
<div className="pf-example">
<div className="ex-h">示例图</div>
<div className="ex-grid">
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z" /><path d="M9 4v3M15 4v3M9 11h6M9 14h6" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="5" width="12" height="15" rx="2" /><path d="M9 9h6M9 12h6M9 15h4" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z" /><circle cx="12" cy="13" r="2.5" /></svg></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
</div>
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
</div>
@@ -350,6 +360,9 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
</button>
</div>
</aside>
</>,
document.body,
)}
</section>
);
}
@@ -590,6 +603,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12);
const [videoSortDesc, setVideoSortDesc] = useState(true);
// 图片预览灯箱
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
useEffect(() => {
if (!openFilter) return;
@@ -709,7 +724,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img">
<div className="placeholder prod-preview-img" id="ov-tri-img" role={triUrl ? "button" : undefined} tabIndex={triUrl ? 0 : undefined} title={triUrl ? "点击放大" : undefined} style={triUrl ? { cursor: "zoom-in" } : undefined} onClick={triUrl ? () => setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}>
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
</div>
<div className="prod-preview-foot" id="ov-tri-foot">
@@ -793,9 +808,15 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
</div>
<div className="grid" id="ov-images-grid">
{productImages.map((image) => (
<div className="thumb placeholder" key={image.id}>
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
</div>
image.url ? (
<div className="thumb placeholder" key={image.id} role="button" tabIndex={0} title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: image.url, name: realName })} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: image.url, name: realName }); } }}>
<img src={image.url} alt={realName} loading="lazy" />
</div>
) : (
<div className="thumb placeholder" key={image.id}>
<span className="ph-frame">1:1</span>
</div>
)
))}
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
{uploading ? (
@@ -896,7 +917,7 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
const status: "pass" | "fail" | "archive" = "pass";
return (
<div className="asset-card" key={asset.id}>
<div className="thumb placeholder">
<div className="thumb placeholder" role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} style={url ? { cursor: "zoom-in" } : undefined} onClick={url ? () => setPreview({ src: url, name: asset.name }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, name: asset.name }); } } : undefined}>
{url ? <img src={url} alt={asset.name} loading="lazy" /> : null}
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
{url ? null : <span className="ph-frame">3:4</span>}
@@ -934,6 +955,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
</div>
</div>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</section>
);
}
+1
View File
@@ -188,6 +188,7 @@ export function SettingsPage({
setName(user.username || "");
setEmail(user.email || "");
setPhone("");
onNotify?.("已恢复为已保存的资料");
}
async function handleSaveProfile() {
+13 -4
View File
@@ -254,18 +254,27 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
</thead>
<tbody id="members-tbody">
{list.map((member) => {
const name = member.user.username || member.user.email || "成员";
const rawName = (member.user.username || "").trim();
const email = (member.user.email || "").trim();
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
const role = roleUi(member.role);
const monthly = Number(member.monthly_credit_limit || 0);
const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
const memberUsed = Number(member.month_charged || 0);
// 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」
const quotaDenom = monthly > 0 ? monthly : limit;
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
const barClass = memberPct >= 80 ? "warn" : "ok";
const isOwner = member.role === "owner";
return (
<tr key={member.id} data-id={member.id}>
<td><span className="member-cell"><span className="av">{name.slice(0, 1).toUpperCase()}</span><span><span className="nm">{name}</span><span className="em">{member.user.email || ""}</span></span></span></td>
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
<td><span className="quota-cell"><span className="v">不限</span></span></td>
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
<td><div className="quota-cell"><span className="v">¥0.00</span> <span className="lbl">/ {memberPct.toFixed(0)}%</span></div><div className="used-bar"><span className="ok" style={{ width: `${memberPct.toFixed(0)}%` }}></span></div></td>
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
<td><div className="acts">{isOwner
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}>不可编辑</span>
: <>