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}