feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:42 +08:00
co-authored by Claude Opus 4.8
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions
+125 -2
View File
@@ -1,8 +1,97 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
{ id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
];
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
return SHELL_COMMANDS.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
}, [query]);
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
if (!open) return null;
let lastGroup = "";
return createPortal(
<div
id="shell-command-bg"
className="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" />
<input
id="shell-command-input"
autoFocus
placeholder="搜索页面、动作…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
else if (event.key === "Enter" && items[0]) { event.preventDefault(); run(items[0]); }
}}
/>
<span id="shell-command-count" className="shell-command-count">{items.length} </span>
<button id="shell-command-close" type="button" className="shell-command-close" aria-label="关闭" onClick={onClose}>Esc</button>
</div>
<div id="shell-command-list" className="shell-command-list">
{items.length === 0 && (
<div className="shell-command-empty">
<IconKitSvg name="search" />
<span></span>
<span className="shell-command-section">// 换个关键词试试</span>
</div>
)}
{items.map((cmd, i) => {
const section = cmd.group !== lastGroup ? <div className="shell-command-section">{cmd.group}</div> : null;
lastGroup = cmd.group;
return (
<div key={cmd.id}>
{section}
<button className={`shell-command-item${i === 0 ? " active" : ""}`} type="button" onClick={() => run(cmd)}>
<span className="cmd-ic"><IconKitSvg name={cmd.icon} /></span>
<span className="cmd-main">
<span className="cmd-title">{cmd.label}</span>
<span className="cmd-sub">{cmd.sub}</span>
</span>
{cmd.key && <span className="cmd-key">{cmd.key}</span>}
</button>
</div>
);
})}
</div>
</div>
</div>,
document.body
);
}
type Navigate = (page: Page) => void;
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
@@ -50,18 +139,50 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
const activeNav = PAGE_TO_NAV[page];
const badges: Partial<Record<string, number>> = { products: products.length, projects: projects.length };
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1");
useEffect(() => {
document.body.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<>
<aside className="sidebar">
<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>
</a>
</div>
<button className="sidebar-toggle" type="button" aria-label="收窄导航" title="收窄导航">
<button
className="sidebar-toggle"
type="button"
aria-pressed={collapsed}
aria-label={collapsed ? "展开导航" : "收窄导航"}
title={collapsed ? "展开导航" : "收窄导航"}
onClick={() => setCollapsed((value) => !value)}
>
<span className="sidebar-toggle-icon sidebar-toggle-icon--collapse"><IconKitSvg name="chevronLeft" size={18} strokeWidth={1.8} /></span>
<span className="sidebar-toggle-icon sidebar-toggle-icon--expand"><IconKitSvg name="chevronRight" size={18} strokeWidth={1.8} /></span>
</button>
<div className="search-box" title="搜索">
<div className="search-box" title="搜索 (Ctrl K)" role="button" tabIndex={0} onClick={openCommandPalette} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openCommandPalette(); } }}>
<IconKitSvg name="search" />
<input id="global-search" placeholder="搜索" readOnly aria-label="打开全局搜索" />
<span className="kbd">Ctrl K</span>
@@ -90,6 +211,8 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
</div>
</div>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
</>
);
}