This commit is contained in:
Azmat@qq.com
2026-08-20 12:52:05 +08:00
parent c7520e93b7
commit ba1613f2b5
32 changed files with 336 additions and 139 deletions
+90 -13
View File
@@ -43,7 +43,7 @@ import {
TeamPage
} from "./routes";
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
import { isOwnerOnlyPage, pathForPage, resolveRoute } from "./routes/route-config";
import { isOwnerOnlyPage, parentPage, pathForPage, resolveRoute } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
@@ -79,6 +79,25 @@ function saveImgwb(mode: string | undefined, patch: ImgwbSaved) {
}
}
type NavHistoryState = {
airshelf: 1;
scrollY: number;
tab?: string;
from?: {
page: Page;
productId?: string;
projectId?: string;
tab?: string;
hash?: string;
scrollY: number;
};
};
function readNavState(raw: unknown): NavHistoryState | null {
if (!raw || typeof raw !== "object" || !("airshelf" in raw)) return null;
return raw as NavHistoryState;
}
export function App() {
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
const page = route.page;
@@ -116,6 +135,8 @@ export function App() {
const [notice, setNotice] = useState<Notice>(null);
const [loading, setLoading] = useState(false);
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
// 返回上一页时待恢复的滚动位置;null = 前进导航,滚到顶部
const pendingScrollRef = useRef<number | null>(null);
// 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」)
useEffect(() => {
@@ -281,7 +302,9 @@ export function App() {
useEffect(() => {
function syncRouteFromHistory() {
const next = resolveRoute();
setRoute(next);
const state = readNavState(window.history.state);
pendingScrollRef.current = state?.scrollY ?? 0;
setRoute({ ...next, tab: state?.tab ?? next.tab });
setAuthMode(next.authMode);
if (next.productId !== undefined) setActiveProductId(next.productId);
if (next.projectId !== undefined) setActiveProjectId(next.projectId);
@@ -290,6 +313,22 @@ export function App() {
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
useLayoutEffect(() => {
const y = pendingScrollRef.current;
if (y == null) return;
const restore = () => window.scrollTo({ top: y, behavior: "auto" });
restore();
const frame = window.requestAnimationFrame(restore);
const later = window.setTimeout(() => {
restore();
pendingScrollRef.current = null;
}, 120);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(later);
};
}, [page, route.projectId, route.productId]);
// 平台后台 gating(身份就绪后):
// - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
// - 非超管访问 /admin/*:纠回工作台
@@ -487,15 +526,52 @@ export function App() {
if (options.productId !== undefined) setActiveProductId(options.productId);
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
const hash = options.hash?.replace(/^#/, "");
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
const currentPath = `${window.location.pathname}${window.location.hash}`;
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
const prevState = readNavState(window.history.state);
const leaving: NavHistoryState = {
airshelf: 1,
scrollY: window.scrollY || document.documentElement.scrollTop || 0,
tab: route.tab,
from: prevState?.from,
};
if (!options.replace) {
window.history.replaceState(leaving, "", currentPath);
}
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
const arriving: NavHistoryState = {
airshelf: 1,
scrollY: 0,
tab: options.tab,
from: options.replace
? prevState?.from
: {
page,
productId: route.productId,
projectId: route.projectId,
tab: route.tab,
hash: route.hash,
scrollY: leaving.scrollY,
},
};
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
const method = options.replace ? "replaceState" : "pushState";
window.history[method](null, "", path);
window.history[method](arriving, "", path);
} else {
window.history.replaceState(arriving, "", path);
}
pendingScrollRef.current = null;
window.scrollTo({ top: 0, behavior: "auto" });
}
function goBack(fallback: Page = parentPage(page)) {
if (readNavState(window.history.state)?.from?.page) {
window.history.back();
return;
}
navigate(fallback, { replace: true });
}
// 平台超管后台导航:section="" → /admin(概览),否则 /admin/<section>。
function navigateAdmin(section: string, options: { replace?: boolean } = {}) {
const path = section ? `/admin/${section}` : "/admin";
@@ -878,7 +954,7 @@ export function App() {
products={products}
projects={projects}
preselectProductId={activeProductId}
onBack={() => navigate("projects")}
onBack={() => goBack("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
if (created) {
@@ -886,7 +962,7 @@ export function App() {
// (旧项目的脚本会污染新项目的脚本助手 chat)
setProjectDetail(created);
setProjects((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev]));
navigate("pipeline", { projectId: created.id });
navigate("pipeline", { projectId: created.id, replace: true });
}
}}
onCreateProduct={(payload) => action(() => api.createProduct(payload), "")}
@@ -968,17 +1044,17 @@ export function App() {
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => navigate("projects")} />;
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
case "settingsNotify":
@@ -1012,7 +1088,7 @@ export function App() {
actions={projectDetailError ? (
<>
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}></button>
<button className="btn btn-ghost" type="button" onClick={() => navigate("projects")}></button>
<button className="btn btn-ghost" type="button" onClick={() => goBack("projects")}></button>
</>
) : undefined}
/>
@@ -1027,6 +1103,7 @@ export function App() {
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
loading={loading}
navigate={navigate}
onBack={() => goBack("projects")}
user={currentUser}
team={currentTeam}
products={products}
+5
View File
@@ -14,6 +14,11 @@
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.ac-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.ac-head {
min-height: 76px;
display: flex;
+7 -7
View File
@@ -18,7 +18,7 @@
width: 100%;
max-width: none;
box-sizing: border-box;
/* 抵消 .content 的 24/28/60换成影擎 .main 的左右留白,三张入口卡铺满主区 */
/* 抵消 .content 的 24/28/60网格底铺满主区。正文对照影擎 .content:1560 居中 */
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
@@ -26,6 +26,11 @@
.asset-factory { margin: -28px -24px -48px; }
}
.asset-factory .asset-factory-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.asset-factory .af-page-head {
min-height: 76px;
display: flex;
@@ -320,7 +325,7 @@
.asset-factory .af-media-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr));
gap: 18px;
margin-top: 18px;
}
@@ -469,16 +474,11 @@
margin-bottom: 6px;
}
@media (max-width: 1280px) {
.asset-factory .af-media-grid:not(.list) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.asset-factory .af-tools { grid-template-columns: 1fr; }
.asset-factory .af-toolbar { flex-wrap: wrap; }
.asset-factory .af-media-grid:not(.list) { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.asset-factory .af-media-grid:not(.list) { grid-template-columns: 1fr; }
.asset-factory .af-media-grid.list .af-media { grid-template-columns: 1fr; }
.asset-factory .af-media-grid.list .af-media-thumb { width: 100%; height: auto; aspect-ratio: 16 / 10; }
}
+99 -72
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Boxes, Check, ChevronDown, Info, LogOut, Settings, UsersRound } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock, useOverlayTransition } from "./overlays";
import { ConfirmModal, useBodyScrollLock, useOverlayTransition } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -165,37 +165,50 @@ export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user,
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
const go = (page: Page) => { onClose(); navigate(page); };
if (!mounted || !rect) return null;
const [confirmLogout, setConfirmLogout] = useState(false);
return createPortal(
<div
ref={menuRef}
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
style={{ left: pos.left, top: pos.top }}
>
<div className="shell-account-head">
<span className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</span>
<span>
<span className="nm">{team?.name || user.username}</span>
<span className="mail">{user.username}</span>
</span>
</div>
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).map((item) => (
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
<IconKitSvg name={item.icon} />
{item.label}
</button>
))}
<div className="sep" />
<button type="button" role="menuitem" onClick={() => { onClose(); logout(); }}>
<LogOut size={14} />
退
</button>
</div>,
document.body
return (
<>
{mounted && rect ? createPortal(
<div
ref={menuRef}
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
style={{ left: pos.left, top: pos.top }}
>
<div className="shell-account-head">
<span className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</span>
<span>
<span className="nm">{team?.name || user.username}</span>
<span className="mail">{user.username}</span>
</span>
</div>
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).map((item) => (
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
<IconKitSvg name={item.icon} />
{item.label}
</button>
))}
<div className="sep" />
<button type="button" role="menuitem" onClick={() => { onClose(); setConfirmLogout(true); }}>
<LogOut size={14} />
退
</button>
</div>,
document.body
) : null}
<ConfirmModal
open={confirmLogout}
title="退出当前账号"
icon={<LogOut size={16} />}
detail="确认后将退出当前设备,再次使用需要重新登录。项目、资产、团队成员与余额数据都会保留。"
confirmText="确认退出"
onCancel={() => setConfirmLogout(false)}
onConfirm={() => { setConfirmLogout(false); logout(); }}
/>
</>
);
}
@@ -262,93 +275,107 @@ const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "video", label: "视频创作", page: "projects" },
];
// 顶栏三按钮滑动黑胶囊 · 对照影擎 .mode-tabs / moveModeIndicator 原样接入:
// clip-path 只露出三个按钮形状,胶囊在缝里看不见;位移走 transform,不在 React state 里改尺寸。
// 顶栏三按钮滑动黑胶囊 · 结构 / 类名 / moveModeIndicator 均按影擎 HTML 原样转写。
export function ModeTabs({ active, navigate }: { active: TopModule | null; navigate: Navigate }) {
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const clipRectsRef = useRef<Array<SVGRectElement | null>>([]);
const rootRef = useRef<HTMLElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const firstPaint = useRef(true);
const activeRef = useRef(active);
const [visualActive, setVisualActive] = useState(active);
activeRef.current = visualActive ?? active;
useLayoutEffect(() => {
setVisualActive(active);
}, [active]);
const moveModeIndicator = (tab: HTMLButtonElement, animate = true) => {
btnRefs.current.forEach((item, index) => {
const rect = clipRectsRef.current[index];
if (!item || !rect) return;
const root = rootRef.current;
const modeIndicator = indicatorRef.current;
if (!root || !modeIndicator) return;
const modeTabs = [...root.querySelectorAll<HTMLButtonElement>(".mode-tab")];
const modeMaskRects = [...root.querySelectorAll<SVGRectElement>("#modeButtonMask rect")];
modeTabs.forEach((item, index) => {
const rect = modeMaskRects[index];
if (!rect) return;
rect.setAttribute("x", String(item.offsetLeft));
rect.setAttribute("y", "0");
rect.setAttribute("width", String(item.offsetWidth));
rect.setAttribute("height", String(item.offsetHeight));
});
const modeIndicator = indicatorRef.current;
if (!modeIndicator) return;
if (!animate) modeIndicator.style.transition = "none";
else {
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
modeIndicator.style.width = `${tab.offsetWidth}px`;
modeIndicator.style.transform = `translateX(${tab.offsetLeft}px)`;
if (!animate) {
void modeIndicator.offsetWidth;
modeIndicator.style.transition = "";
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
};
const activeButton = () => {
const idx = active ? MODE_TABS.findIndex((tab) => tab.id === active) : -1;
return idx >= 0 ? btnRefs.current[idx] : null;
const tabFor = (id: TopModule | null) => {
const root = rootRef.current;
if (!root || !id) return null;
const idx = MODE_TABS.findIndex((tab) => tab.id === id);
return idx >= 0 ? root.querySelectorAll<HTMLButtonElement>(".mode-tab")[idx] ?? null : null;
};
const firstPaint = useRef(true);
useLayoutEffect(() => {
const tab = activeButton();
const tab = tabFor(active);
if (tab) moveModeIndicator(tab, !firstPaint.current);
firstPaint.current = false;
}, [active]);
useEffect(() => {
const snap = () => {
const tab = activeButton();
const tab = tabFor(activeRef.current);
if (tab) moveModeIndicator(tab, false);
};
window.addEventListener("resize", snap);
void document.fonts?.ready.then(snap);
return () => window.removeEventListener("resize", snap);
}, [active]);
}, []);
return (
<div
className={`yz-mods${active ? "" : " no-active"}`}
role="tablist"
aria-label="作模"
<nav
ref={rootRef}
className={`mode-tabs${visualActive || active ? "" : " no-active"}`}
aria-label="作模"
>
<svg className="yz-mod-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
{/* clip-path:url(#id) 必须写在文档内 <style> 里,外部 CSS 文件解析不到这个 SVG id */}
<style>{`
.mode-tabs .mode-indicator-mask {
clip-path: url(#modeButtonMask);
-webkit-clip-path: url(#modeButtonMask);
}
`}</style>
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<clipPath id="yzModeButtonMask" clipPathUnits="userSpaceOnUse">
<rect ref={(el) => { clipRectsRef.current[0] = el; }} rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect ref={(el) => { clipRectsRef.current[1] = el; }} rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect ref={(el) => { clipRectsRef.current[2] = el; }} rx="22" ry="22" x="246" y="0" width="110" height="44" />
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
</clipPath>
</defs>
</svg>
<span className="yz-mod-indicator-mask" aria-hidden="true">
<span
ref={indicatorRef}
className="yz-mod-indicator"
style={{ width: 104, transform: "translateX(0px)" }}
/>
<span className="mode-indicator-mask" aria-hidden="true">
<span ref={indicatorRef} className="mode-indicator" id="modeIndicator" />
</span>
{MODE_TABS.map((tab, index) => (
{MODE_TABS.map((tab) => (
<button
key={tab.id}
ref={(el) => { btnRefs.current[index] = el; }}
className={`yz-mod${active === tab.id ? " active" : ""}`}
className={`mode-tab${(visualActive ?? active) === tab.id ? " active" : ""}`}
type="button"
role="tab"
aria-selected={active === tab.id}
onClick={() => {
const btn = btnRefs.current[index];
if (btn) moveModeIndicator(btn, true);
data-page={tab.id}
onClick={(event) => {
setVisualActive(tab.id);
moveModeIndicator(event.currentTarget, true);
navigate(tab.page);
}}
>{tab.label}</button>
))}
</div>
</nav>
);
}
+7 -3
View File
@@ -12,12 +12,17 @@
width: 100%;
max-width: none;
box-sizing: border-box;
/* 抵消 .content 的 24/28/60换成影擎 .main 左右留白,工作台铺满主区 */
/* 抵消 .content 的 24/28/60网格底铺满主区。正文对照影擎 .content:1560 居中 */
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
@media (max-width: 1100px) {
.dashboard-page { margin: -28px -24px -48px; }
.dashboard-page { margin: -28px -24px -48px; padding: 40px 24px 48px; }
}
.dashboard-page .dashboard-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.dashboard-page .welcome h1 {
@@ -37,7 +42,6 @@
.dashboard-page .welcome,
.dashboard-page .creation {
width: min(1140px, 100%);
margin-right: auto;
}
.dashboard-page .creation { margin-top: 40px; }
.dashboard-page .creation-head {
+14 -22
View File
@@ -779,7 +779,8 @@ main { position: relative; background: #fff; min-width: 0; }
.crumbs .sep { color: var(--black-alpha-32); }
.crumbs .here { color: var(--accent-black); font-weight: 500; }
.crumbs a:hover { color: var(--accent-black); }
.topbar .yz-mods {
/* 顶栏工作模式 · 影擎 .mode-tabs / .mode-tab / moveModeIndicator 原样 */
.topbar .mode-tabs {
position: relative;
display: flex;
flex-direction: row;
@@ -789,25 +790,23 @@ main { position: relative; background: #fff; min-width: 0; }
width: max-content;
max-width: none;
gap: 16px;
min-height: 44px;
}
.topbar .yz-mods.no-active .yz-mod-indicator-mask { opacity: 0; }
.yz-mod-clip-defs {
.topbar .mode-tabs.no-active .mode-indicator-mask { opacity: 0; }
.mode-clip-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
max-width: none;
}
.yz-mod-indicator-mask {
.mode-indicator-mask {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
clip-path: url(#yzModeButtonMask);
-webkit-clip-path: url(#yzModeButtonMask);
transition: opacity 180ms ease;
}
.yz-mod-indicator {
.mode-indicator {
position: absolute;
top: 0;
left: 0;
@@ -817,43 +816,38 @@ main { position: relative; background: #fff; min-width: 0; }
background: #101012;
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.16);
pointer-events: none;
transform: translateX(0px);
transform-origin: left center;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform, width;
}
.topbar .yz-mods .yz-mod {
.topbar .mode-tabs .mode-tab {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: auto;
min-width: 104px;
height: 44px;
padding: 0 22px;
gap: 0;
border: 1px solid #d9dde3;
border-radius: 22px;
color: #2b2e33;
background: rgba(31, 39, 51, 0.025);
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: center;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
transition: background 180ms ease, color 180ms ease, border-color 180ms ease;
}
.topbar .yz-mods .yz-mod:hover:not(.active) { background: rgba(31, 39, 51, 0.06); color: #2b2e33; }
.topbar .yz-mods .yz-mod.active {
.topbar .mode-tabs .mode-tab.active {
color: #fff;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.topbar .yz-mods .yz-mod.active:hover { background: transparent; color: #fff; }
@media (max-width: 1500px) {
.topbar .mode-tabs { gap: 9px; }
.topbar .mode-tabs .mode-tab { min-width: 88px; padding: 0 16px; }
}
.top-search {
display: inline-flex;
align-items: center;
@@ -2883,8 +2877,6 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
.dash-funds-mono:hover { color: var(--heat); }
@media (prefers-reduced-motion: reduce) {
.yz-mod-indicator,
.yz-mod-indicator-mask,
.modal-bg,
.modal,
.drawer,
+9
View File
@@ -30,6 +30,15 @@
.fc-page { margin: -28px -24px -48px; }
}
.fc-page .fc-inner {
width: min(1560px, 100%);
margin: 0 auto;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.fc-page .fc-head {
min-height: 76px;
display: flex;
+8 -1
View File
@@ -19,6 +19,11 @@
.library-page { margin: -28px -24px -48px; }
}
.library-page .lib-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.library-page .lib-head {
min-height: 76px;
display: flex;
@@ -410,8 +415,10 @@
.library-page .lib-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
.library-page .list-pager { margin-top: 20px; }
@media (max-width: 1100px) {
@media (max-width: 1500px) {
.library-page .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.library-page .lib-sel-bar { left: 50%; }
.library-page .lib-toolbar { flex-wrap: wrap; }
}
+10
View File
@@ -19,6 +19,16 @@
.msg-page { margin: -28px -24px -48px; }
}
.msg-page .msg-inner {
width: min(1560px, 100%);
margin: 0 auto;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg-page .page-head {
display: flex;
align-items: flex-start;
+9 -5
View File
@@ -18,6 +18,11 @@
.models-page { margin: -28px -24px -48px; }
}
.models-page .ml-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.models-page .ml-head {
min-height: 76px;
display: flex;
@@ -105,8 +110,8 @@
.models-page .ml-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.models-page .ml-card {
position: relative;
@@ -254,11 +259,10 @@
.models-page .ml-sel-confirm { color: #fff; background: #002fa7; }
.models-page .ml-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1280px) {
.models-page .ml-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 1500px) {
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.models-page .ml-sel-bar { left: 50%; }
}
@media (max-width: 860px) {
@@ -7,6 +7,11 @@
全部规则 scope .product-create-page ,避免污染他页
============================================================ */
.product-create-page .pc-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
/* ─── 主表单双栏 ─── */
.product-create-page .form-grid {
display: grid;
@@ -5,6 +5,11 @@
}
.product-detail-page {
.pd-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
/* ─── 顶部 标题 + 状态 ─── */
.pd-title {
display: flex; align-items: center; gap: 12px;
+8 -1
View File
@@ -14,6 +14,11 @@
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.pl-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.pl-head {
min-height: 76px;
display: flex;
@@ -447,9 +452,11 @@
.list-pager { margin-top: 20px; }
}
@media (max-width: 1500px) {
.products-page .pl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.products-page { margin: -28px -24px -48px; }
.products-page .pl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 860px) {
.products-page .pl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+7 -6
View File
@@ -19,6 +19,11 @@
.projects-page { margin: -28px -24px -48px; }
}
.projects-page .projects-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.projects-page .vc-head {
min-height: 76px;
display: flex;
@@ -288,7 +293,8 @@
.projects-page .vc-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
/* 列宽贴筛选条搜索框(~250px),宽屏加列而不是把卡片拉超大 */
grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr));
gap: 18px;
margin-top: 18px;
}
@@ -504,18 +510,13 @@
border-color: var(--klein);
}
@media (max-width: 1280px) {
.projects-page .vc-grid:not(.list) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.projects-page .vc-tools.two,
.projects-page .vc-tools.three { grid-template-columns: 1fr; }
.projects-page .vc-toolbar { flex-wrap: wrap; }
.projects-page .vc-grid:not(.list) { grid-template-columns: 1fr 1fr; }
.projects-page .vc-sel-bar { left: 50%; }
}
@media (max-width: 720px) {
.projects-page .vc-grid:not(.list) { grid-template-columns: 1fr; }
.projects-page .vc-search { width: 100%; }
}
+2
View File
@@ -287,6 +287,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
return (
<section className="account-page">
<div className="ac-inner">
<header className="ac-head">
<div>
<h1></h1>
@@ -617,6 +618,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
</table>
</div>
</div>
</div>
<TopupModal
open={topupChannel !== null}
+4 -2
View File
@@ -265,6 +265,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return (
<div className="asset-factory">
<div className="asset-factory-inner">
<header className="af-page-head">
<div>
<h1></h1>
@@ -384,6 +385,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</div>
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
@@ -1600,7 +1602,7 @@ export function ImageWorkbenchPage({
<div className="yz-image image-workbench">
<header className="image-subpage-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回图片创作" onClick={onBack}>
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
<ArrowLeft />
</button>
<div>
@@ -1811,7 +1813,7 @@ export function ImageWorkbenchPage({
<div className="yz-image image-workbench">
<header className="image-subpage-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回图片创作" onClick={onBack}>
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
<ArrowLeft />
</button>
<div>
+2
View File
@@ -128,6 +128,7 @@ export function Dashboard({
return (
<section className="dashboard-page">
<div className="dashboard-inner">
<section className="welcome">
<h1>{greetName ? `${greetName}` : ""}</h1>
<p>{dateLabel} · {running}</p>
@@ -258,6 +259,7 @@ export function Dashboard({
</div>
)}
</section>
</div>
</section>
);
}
+2
View File
@@ -529,6 +529,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
return (
<div className="fc-page">
<div className="fc-inner">
<header className="fc-head">
<div className="fc-title-row">
{onBack ? (
@@ -606,6 +607,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
onClear={clearInput}
onSend={() => void handleSend()}
/>
</div>
{detailTask && (
<VideoDetailModal
+2
View File
@@ -661,6 +661,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
return (
<section className={`library-page${editMode ? " manage-mode" : ""}`}>
<div className="lib-inner">
<header className="lib-head">
<div>
<h1></h1>
@@ -978,6 +979,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
) : (
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span></span></div>
)}
</div>
<div className={`lib-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="lib-sel-copy">
+2
View File
@@ -363,6 +363,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
return (
<div className="msg-page">
<div className="msg-inner">
<div className="page-head">
<button className="msg-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
<ArrowLeft size={18} strokeWidth={1.75} />
@@ -537,6 +538,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
)}
</section>
</div>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
+2
View File
@@ -344,6 +344,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
return (
<section className={`models-page${editMode ? " manage-mode" : ""}`}>
<div className="ml-inner">
<header className="ml-head">
<div>
<h1></h1>
@@ -448,6 +449,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
})}
</div>
)}
</div>
<div className={`ml-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="ml-sel-copy">
+2 -1
View File
@@ -500,6 +500,7 @@ export function PipelinePage(props: {
project: Project;
loading: boolean;
navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void;
onBack?: () => void;
user: User;
team: Team;
products: Product[];
@@ -2809,7 +2810,7 @@ export function PipelinePage(props: {
<section className="pipeline-page">
<header className="pl-head">
<div className="pl-title">
<button className="pl-back" type="button" onClick={() => navigate("projects")} aria-label="返回视频创作">
<button className="pl-back" type="button" onClick={() => (props.onBack ? props.onBack() : navigate("projects"))} aria-label="返回">
<ArrowLeft />
</button>
<div>
+6
View File
@@ -207,6 +207,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
return (
<section className={`products-page${editMode ? " manage-mode" : ""}`}>
<div className="pl-inner">
<header className="pl-head">
<div>
<h1></h1>
@@ -319,6 +320,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</>
)}
</div>
<div className={`pl-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="pl-sel-copy">
@@ -473,6 +475,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
return (
<section className="product-create-page">
<div className="pc-inner">
<div className="page-head">
<div>
<h1></h1>
@@ -587,6 +590,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div>
</div>
</form>
</div>
</section>
);
}
@@ -952,6 +956,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
return (
<section className="product-detail-page">
<div className="pd-inner">
{/* 顶部 标题 + 状态 */}
<div className="pd-title">
@@ -1336,6 +1341,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
})}
</div>
</div>
</div>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
+3 -1
View File
@@ -199,7 +199,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
<section className="project-wizard-page">
<header className="nw-head">
<div className="nw-title">
<button className="nw-back" type="button" onClick={onBack} aria-label="返回视频创作">
<button className="nw-back" type="button" onClick={onBack} aria-label="返回">
<ArrowLeft />
</button>
<div>
@@ -566,6 +566,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return (
<section className={`projects-page${editMode ? " manage-mode" : ""}`}>
<div className="projects-inner">
<header className="vc-head">
<div>
<h1></h1>
@@ -750,6 +751,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
</>
)}
</div>
<div className={`vc-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="vc-sel-copy">
+1 -1
View File
@@ -118,7 +118,7 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
if (page === "projectWizard") return "projects";
if (["projectWizard", "freeCreate", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory";
}
+2
View File
@@ -387,6 +387,7 @@ export function SettingsPage({
return (
<section className="settings-page">
<div className="settings-inner">
<div className="page-head">
<div>
<h1></h1>
@@ -682,6 +683,7 @@ export function SettingsPage({
</main>
</div>
</div>
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */}
<TeamModal
+2
View File
@@ -345,6 +345,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
return (
<section className="team-page">
<div className="team-inner">
<div className="page-head">
<div>
<h1></h1>
@@ -580,6 +581,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</table>
</div>
</section>
</div>
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
<TeamModal
+2
View File
@@ -309,6 +309,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
return (
<div className="trash-page">
<div className="trash-inner">
<div className="page-head">
<div>
<h1></h1>
@@ -371,6 +372,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
))}
</>
)}
</div>
<ConfirmModal
open={purgeAllOpen}
+5
View File
@@ -15,6 +15,11 @@
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.settings-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.page-head { margin-bottom: 22px; }
.page-head h1 { font-size: 28px; }
.page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); }
+4 -4
View File
@@ -624,7 +624,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── React implementation: keeps the v1 visual language while binding real data ─── */
nav button {
.sidebar nav button {
display: flex; align-items: center; gap: 11px;
padding: 8px 12px;
color: var(--ink-2);
@@ -635,9 +635,9 @@ nav button {
width: 100%;
text-align: left;
}
nav button:hover { background: var(--bg-soft); color: var(--ink); }
nav button.active { background: var(--orange-tint); color: var(--orange); }
nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
.sidebar nav button:hover { background: var(--bg-soft); color: var(--ink); }
.sidebar nav button.active { background: var(--orange-tint); color: var(--orange); }
.sidebar nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
.crumbs button { color: var(--ink-3); }
.crumbs button:hover { color: var(--ink); }
+5
View File
@@ -13,6 +13,11 @@
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.team-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.page-head { margin-bottom: 34px; }
.page-head h1 { font-size: 32px; line-height: 1.2; letter-spacing: -.02em; }
.page-head .sub { margin-top: 10px; font-size: 15px; color: var(--st-muted); }
+5
View File
@@ -1,4 +1,9 @@
/* 垃圾桶 · 已删除商品(软删 status=archived)+ 已删除资产(is_deleted,R108)· 仅用 design-restraint token */
.trash-page .trash-inner {
width: min(1560px, 100%);
margin: 0 auto;
}
.trash-empty { min-height: 220px; flex-direction: column; gap: 10px; }
/* 分区(商品 / 资产):mono 小标题 + 各自列表 */