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
+7 -8
View File
@@ -107,23 +107,23 @@ export function App() {
await Promise.all([
api.products(),
api.projects(),
api.assets(),
api.allAssets(),
api.billingSummary().catch(() => null),
api.ledgers().catch(() => []),
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
api.billingTrend().catch(() => null),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null),
api.listNotifications().catch(() => null)
api.allNotifications().catch(() => null)
]);
setProducts(productData.results);
setProjects(projectData.results);
setAssets(assetData.results);
setAssets(assetData);
setTeamMembers(memberData);
setModelConfigs(modelData?.results || []);
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData);
setLedgers(ledgerData.results);
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
@@ -161,7 +161,7 @@ export function App() {
}
const reloadNotifications = useCallback(async () => {
const data = await api.listNotifications().catch(() => null);
const data = await api.allNotifications().catch(() => null);
if (data) {
setNotifications(data.results);
setUnreadCount(data.unread_count);
@@ -513,7 +513,6 @@ export function App() {
case "messages":
return (
<MessagesPage
notifications={notifications}
unreadCount={unreadCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
@@ -521,7 +520,7 @@ export function App() {
/>
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhoto":
+21 -3
View File
@@ -97,8 +97,8 @@
.trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }
.trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }
.trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; }
.trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--heat); border-radius: 2px 2px 0 0; }
.trend-chart .bar:hover > span { background: var(--accent-black); }
.trend-chart .bar.peak > span { background: var(--accent-black); }
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
@@ -127,14 +127,23 @@
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
.billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }
.billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 12.5px; border-bottom: 0; }
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
/* 行间淡分隔线,长流水更易扫读;最后一行不画 */
.billing-table tbody td { border-bottom: 1px solid var(--border-faint); }
.billing-table tbody tr:last-child td { border-bottom: 0; }
.billing-table tbody tr:hover { background: var(--background-lighter); }
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
/* 已用 / 月度额度:已用部分本色,额度部分弱化,整列左对齐 */
.billing-table .quota { font-variant-numeric: tabular-nums; text-align: left; }
.billing-table .quota .used { font-weight: 500; color: var(--accent-black); }
.billing-table .quota .lim { color: var(--black-alpha-32); }
.billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }
/* 系统流水(无成员):弱化的 mono 占位,不喧宾夺主 */
.billing-table .sys { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); }
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
@@ -150,6 +159,15 @@
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
.bill-pager { display: flex; align-items: center; gap: 12px; margin-top: 14px; font-size: 12px; }
.bill-pager .total { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.bill-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
.bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.bill-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
.bill-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
.bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; user-select: none; }
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
+7
View File
@@ -158,6 +158,8 @@
font-family: var(--font-mono); font-size: 11px;
letter-spacing: .04em; margin-bottom: 6px;
}
.asset-factory .task-load-more { display: flex; justify-content: center; margin-top: 18px; }
.asset-factory .task-load-more .lm-rest { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-left: 6px; }
/* ============================================================
B · 图片工作室壳(.image-workbench)
@@ -1529,6 +1531,11 @@
.asset-factory .history-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: grid; grid-template-columns: 78px 1fr; gap: 14px; align-items: center; transition: background var(--t-base); position: relative; }
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
/* 任务结果图:有真实生成图时铺满占位框,去掉斜纹底与编号 */
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; }
.asset-factory .placeholder.has-img::after { display: none; }
.asset-factory .placeholder.has-img .ph-frame { display: none; }
.asset-factory .placeholder.has-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.asset-factory .history-body { min-width: 0; }
.asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
.asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
+34 -3
View File
@@ -231,14 +231,26 @@ export const api = {
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
async allAssets(): Promise<Asset[]> {
const out: Asset[] = [];
let path = "/api/assets/";
for (let guard = 0; guard < 50 && path; guard += 1) {
const page: Paginated<Asset> = await request<Paginated<Asset>>(path);
out.push(...page.results);
path = page.next ? new URL(page.next).pathname + new URL(page.next).search : "";
}
return out;
},
uploadAsset(formData: FormData) {
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
ledgers() {
return request<Ledger[]>("/api/billing/ledgers/");
ledgers(page = 1, pageSize = 10) {
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
},
billingTrend(range?: "day" | "week" | "month") {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
@@ -255,13 +267,32 @@ export const api = {
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
listNotifications(params?: { type?: string; unread?: boolean }) {
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
const query = new URLSearchParams();
if (params?.type && params.type !== "all") query.set("type", params.type);
if (params?.unread) query.set("unread", "1");
if (params?.search) query.set("search", params.search);
if (params?.page) query.set("page", String(params.page));
if (params?.pageSize) query.set("page_size", String(params.pageSize));
const qs = query.toString();
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
},
// 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路
async allNotifications(): Promise<NotificationList> {
const out: Notification[] = [];
let path = "/api/ops/notifications/?page_size=100";
let unreadCount = 0;
let typeCounts: NotificationList["type_counts"];
for (let guard = 0; guard < 100 && path; guard += 1) {
const page = await request<NotificationList>(path);
out.push(...page.results);
unreadCount = page.unread_count;
typeCounts = page.type_counts ?? typeCounts;
path = page.next ? new URL(page.next).pathname + new URL(page.next).search : "";
}
return { count: out.length, next: null, previous: null, results: out, unread_count: unreadCount, type_counts: typeCounts };
},
markAllNotificationsRead() {
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
method: "POST"
+3 -3
View File
@@ -39,13 +39,13 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
return createPortal(
<div
id="shell-command-bg"
className="show"
className="shell-command-bg show"
aria-hidden="false"
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
<div className="shell-command-head">
<IconKitSvg name="search" />
<div className="shell-command-h">
<span className="ic"><IconKitSvg name="search" /></span>
<input
id="shell-command-input"
autoFocus
+52 -7
View File
@@ -1,6 +1,45 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Shield, X } from "lucide-react";
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
open: boolean;
src: string;
kind?: "image" | "video";
name?: string;
close: () => void;
}) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
if (!open || !src) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="np-lightbox show" onClick={close}>
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
{kind === "video" ? (
<video
src={src}
controls
autoPlay
playsInline
onClick={(event) => event.stopPropagation()}
style={{ maxWidth: "90vw", maxHeight: "88vh", borderRadius: "var(--r-md)", boxShadow: "0 20px 60px rgba(0,0,0,.5)", background: "#000", cursor: "default" }}
/>
) : (
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
)}
{name && <div className="lb-name">{name}</div>}
</div>,
document.body,
);
}
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
return (
<div className="setting-row">
@@ -20,7 +59,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
footer?: ReactNode;
}) {
if (!open) return null;
return (
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={close}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
@@ -28,7 +68,8 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
<div className="modal-b">{children}</div>
<div className="modal-f"><button className="btn" type="button" onClick={close}></button>{footer || <button className="btn btn-primary" type="button" onClick={close}></button>}</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -41,7 +82,8 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
onConfirm: () => void | Promise<unknown>;
}) {
if (!open) return null;
return (
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={onCancel}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
@@ -49,20 +91,23 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
<div className="modal-b">{detail}</div>
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}></button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
</div>
</div>
</div>,
document.body,
);
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
if (!open) return null;
return (
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<>
<div className="drawer-bg show" onClick={close} />
<aside className="drawer show">
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="drawer-b">{children}</div>
</aside>
</>
</>,
document.body,
);
}
+4
View File
@@ -13,6 +13,10 @@
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
.asset-card { position: relative; }
/* 视频资产缩略图上的播放角标(点击整卡弹窗播放) */
.lib-play-badge { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border-radius: 50%; background: rgba(0, 0, 0, .58); color: var(--accent-white); display: grid; place-items: center; pointer-events: none; transition: background .15s; }
.lib-play-badge svg { width: 18px; height: 18px; margin-left: 2px; }
.asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
}
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
+11 -1
View File
@@ -15,7 +15,10 @@
.msg-workbench {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
min-height: 640px;
/* 收件箱在面板内部滚动(.msg-list 已 overflow:auto)—— 把工作台收进视口高度,
而不是让消息把整页撑高;减去 shell 头 + content 上下内边距 + 页头/页脚 */
height: calc(100vh - 280px);
min-height: 480px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
@@ -128,6 +131,13 @@
min-height: 0;
overflow-y: auto;
}
.msg-load-more {
padding: 14px 16px 18px;
text-align: center;
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .04em;
}
.msg-item {
position: relative;
width: 100%;
@@ -385,6 +385,10 @@
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
pointer-events: none;
}
/* 真实示例图:图片铺满,去掉斜纹底 */
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img { background: var(--background-lighter); }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img::after { display: none; }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pc-drawer .form-card .pf-example .ex-d {
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
}
+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>
: <>
+6 -3
View File
@@ -78,10 +78,13 @@
.pane h3 .spacer { margin-left: auto; }
/* ─── 成员表 ─── */
.members-table .av { width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
.members-table th { white-space: nowrap; }
.members-table .av { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
.members-table .who { display: flex; align-items: center; gap: 10px; }
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; }
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
.members-table .member-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.members-table .member-cell .member-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; }
.members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
+17 -1
View File
@@ -16,6 +16,7 @@ export type TeamMember = {
role: string;
status: string;
monthly_credit_limit: string;
month_charged?: string;
user: User;
};
@@ -56,6 +57,7 @@ export type Asset = {
category: string;
description: string;
metadata?: Record<string, unknown>;
origin_task?: string | null;
files?: Array<{
id: string;
object_key: string;
@@ -190,6 +192,7 @@ export type Ledger = {
balance_after: string;
reason: string;
created_at: string;
user_label?: string;
};
export type UserPreference = {
@@ -260,7 +263,20 @@ export type Notification = {
updated_at: string;
};
export type NotificationList = Paginated<Notification> & { unread_count: number };
export type NotificationTypeCounts = {
all: number;
unread: number;
task: number;
team: number;
billing: number;
system: number;
};
export type NotificationList = Paginated<Notification> & {
unread_count: number;
// 分类 chip 的绝对总数(后端按收件人全量算,不受分页/搜索影响);旧响应可能没有
type_counts?: NotificationTypeCounts;
};
export type RechargeResult = {
account: BillingSummary["account"];