后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+19 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -28,6 +29,7 @@ const SHELL_COMMANDS: Command[] = [
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useBodyScrollLock(open);
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -149,6 +151,16 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 移动端导航抽屉:窄屏侧栏默认收起(display:none),靠左上汉堡键拉出为浮层 + 背景遮罩。
// 点导航项 / 遮罩 / Esc 关闭。桌面端(>1100px)汉堡键 CSS 隐藏,不影响原有收窄逻辑。
const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
if (!mobileNavOpen) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMobileNavOpen(false); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mobileNavOpen]);
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
@@ -165,7 +177,12 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return (
<>
<aside className="sidebar">
{/* 移动端汉堡键(桌面 CSS 隐藏)+ 抽屉遮罩 */}
<button className="mobile-nav-btn" type="button" aria-label="打开导航" aria-expanded={mobileNavOpen} onClick={() => setMobileNavOpen(true)}>
<span /><span /><span />
</button>
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
@@ -196,7 +213,7 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
className={activeNav === item.page ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => { event.preventDefault(); navigate(item.page); }}
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate(item.page); }}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
+27 -3
View File
@@ -1,16 +1,32 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Shield, X } from "lucide-react";
import { Music, Shield, X } from "lucide-react";
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .np-lightbox 样式)
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
// 多浮层叠开用计数,最后一个关闭才解锁。
let scrollLockCount = 0;
export function useBodyScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
scrollLockCount += 1;
document.body.style.overflow = "hidden";
return () => {
scrollLockCount -= 1;
if (scrollLockCount <= 0) { scrollLockCount = 0; document.body.style.overflow = ""; }
};
}, [active]);
}
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
open: boolean;
src: string;
kind?: "image" | "video";
kind?: "image" | "video" | "audio";
name?: string;
close: () => void;
}) {
useBodyScrollLock(open && !!src);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
@@ -31,6 +47,11 @@ export function MediaLightbox({ open, src, kind, name, close }: {
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" }}
/>
) : kind === "audio" ? (
<div className="lb-audio" onClick={(event) => event.stopPropagation()}>
<Music aria-hidden="true" />
<audio src={src} controls autoPlay />
</div>
) : (
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
)}
@@ -58,6 +79,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
children: ReactNode;
footer?: ReactNode;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -81,6 +103,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -97,6 +120,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
+42
View File
@@ -0,0 +1,42 @@
// 通用列表分页器:沿用项目向导 pp-pager / 消费页 bill-pager 的视觉(mono 总数 + 页码窗口 + 每页条数)。
// 纯前端切片分页:父组件持有 page state,自己 slice;本组件只画控件。
// total <= pageSize 时不渲染(单页无需分页器)。
// 页码窗口:超过 7 页折叠成 1 … cur-1 cur cur+1 … last
export 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;
}
export function Pager({ page, total, pageSize, onChange }: {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const cur = Math.min(Math.max(1, page), totalPages);
if (total <= pageSize) return null;
return (
<div className="list-pager">
<span className="total">// 共 {total} 条 · 第 {cur} / {totalPages} 页</span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => onChange(cur - 1)} aria-label="上一页">‹</button>
{pageWindow(cur, totalPages).map((p, i) => (
p === "ellipsis"
? <span key={`e${i}`} className="ellipsis">…</span>
: <button type="button" key={p} className={p === cur ? "active" : ""} onClick={() => onChange(p)}>{p}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => onChange(cur + 1)} aria-label="下一页">›</button>
</div>
<span className="page-size">每页 {pageSize} 条</span>
</div>
);
}