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:
@@ -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":
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 显) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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="删除资产"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export function SettingsPage({
|
||||
setName(user.username || "");
|
||||
setEmail(user.email || "");
|
||||
setPhone("");
|
||||
onNotify?.("已恢复为已保存的资料");
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
|
||||
@@ -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>
|
||||
: <>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user