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