Files
omniai-ds-code-package/src/App.tsx
T
ludan 2a2ab701e3
CI / verify (pull_request) Waiting to run
feat: 工具子页面隐藏Topbar、限制素材上传数量、修复移动端布局
本次修改主要包含以下变更:

一、工具子页面隐藏Topbar(App.tsx / EcommercePage.tsx):
- 新增 onWorkspaceChromeChange 回调,EcommercePage 向 App 层通知当前是否为工具子页面
- 工具子页面(智能抠图/快速详情/水印移除/翻译/图片编辑/一键套图/文案/一键视频等)自动隐藏顶部导航栏
- 组件卸载时重置 isToolPage 状态,避免切换页面时残留

二、素材上传数量限制(EcommercePage.tsx):
- maxCloneProductImages 从 20 张调整为 10 张
- 上传超限时 toast 提示用户「最多上传 10 张素材」
- 新增 AppstoreAddOutlined、HighlightOutlined、TranslationOutlined、PlayCircleOutlined 等图标导入

三、移动端布局修复(ecommerce.css + ecommerce-standalone.css):
- 指令栏容器宽度限制为 calc(100vw - 24px),防止溢出
- 素材缩略图区域改为横向滚动,隐藏滚动条
- 缩略图固定 flex-shrink: 0 防止被压缩(58px / 54px)
- 工具栏宽度 100%,box-sizing 修复
- 新增工具页面相关样式规则

变更文件:
- src/App.tsx (+26)
- src/features/ecommerce/EcommercePage.tsx (+66)
- src/styles/ecommerce-standalone.css (+209)
- src/styles/pages/ecommerce.css (+198)
2026-06-18 13:23:55 +08:00

536 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import {
BugOutlined,
CheckCircleFilled,
CloseOutlined,
HomeOutlined,
LockOutlined,
LoadingOutlined,
LogoutOutlined,
MailOutlined,
MobileOutlined,
SafetyOutlined,
UserOutlined,
} from "@ant-design/icons";
import { LocalAvatar } from "./components/LocalAvatar";
import { Topbar } from "./components/Topbar";
import ErrorBoundary from "./components/ErrorBoundary";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
import { keyServerClient } from "./api/keyServerClient";
import { preloadPublicConfig, getLogoUrl } from "./api/publicConfigClient";
import { preloadPlatformRules } from "./api/platformRulesClient";
import { setUserMaxConcurrency } from "./api/generationConcurrency";
import {
SERVER_SESSION_EXPIRED_EVENT,
SERVER_SESSION_REPLACED_EVENT,
clearAllUserStorage,
type ServerSessionReplacedDetail,
} from "./api/serverConnection";
import { initNotificationPermission } from "./utils/generationNotifier";
import { reportError } from "./utils/errorReporting";
import { loadDarkGreenTheme } from "./styles/loadDarkGreenTheme";
import { useAppStore, useSessionStore } from "./stores";
import type { WebUserSession } from "./types";
import "./styles/ecommerce-standalone.css";
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
type AuthMode = "login" | "register";
type AuthMethod = "account" | "email" | "phone";
type WorkspaceChromeState = {
isToolPage: boolean;
};
interface LocalProfilePageProps {
session: WebUserSession;
balance: number;
imageCount: number;
videoCount: number;
onBack: () => void;
onBugFeedback: () => void;
onLogout: () => void;
}
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
const displayName = session.user.displayName || session.user.username || "用户";
const workCount = Math.max(imageCount + videoCount, 0);
const projectCount = 0;
const assetCount = 0;
return (
<section className="local-profile-page">
<div className="local-profile-page__hero">
<button type="button" className="local-profile-page__back" onClick={onBack}>
<HomeOutlined />
</button>
</div>
<div className="local-profile-page__body">
<aside className="local-profile-card">
<div className="local-profile-card__head">
<span className="local-profile-card__avatar-wrap">
<LocalAvatar session={session} size="lg" />
<CheckCircleFilled />
</span>
<strong>{displayName}</strong>
<span className="local-profile-card__uid">UID {session.user.id}</span>
</div>
<div className="local-profile-card__stats">
<span><strong>{projectCount}</strong><em></em></span>
<span><strong>{workCount}</strong><em></em></span>
<span><strong>{assetCount}</strong><em></em></span>
</div>
<div className="local-profile-card__credits">
<span><em></em><strong>{balance.toFixed(2)}</strong></span>
<span><em></em><strong>{imageCount}</strong></span>
<span><em></em><strong>{videoCount}</strong></span>
</div>
<div className="local-profile-card__meta">
<span><em></em><strong>{displayName}</strong></span>
<span><em></em><strong>{balance.toFixed(2)}</strong></span>
</div>
<button type="button" className="local-profile-card__primary" onClick={onBack}>
<HomeOutlined />
</button>
<button type="button" className="local-profile-card__secondary" onClick={onBugFeedback}>
<BugOutlined />
Bug
</button>
<button type="button" className="local-profile-card__danger" onClick={onLogout}>
<LogoutOutlined />
退
</button>
</aside>
<main className="local-profile-main">
<div className="local-profile-tabs" role="tablist" aria-label="个人主页分类">
{["我的作品", "我的项目", "我的资产", "社区发布"].map((item, index) => (
<button key={item} type="button" className={index === 0 ? "is-active" : ""}>
{item}
</button>
))}
</div>
<section className="local-profile-works">
<header>
<div>
<strong></strong>
<span></span>
</div>
<em>{workCount} </em>
</header>
<div className="local-profile-work-grid local-profile-work-grid--empty">
<div className="local-profile-empty">
<strong></strong>
<span></span>
</div>
</div>
</section>
</main>
</div>
</section>
);
}
function App() {
const session = useSessionStore((s) => s.session);
const setSession = useSessionStore((s) => s.setSession);
const clearSessionState = useSessionStore((s) => s.clearSession);
const usage = useAppStore((s) => s.usage);
const setUsage = useAppStore((s) => s.setUsage);
const [authOpen, setAuthOpen] = useState(false);
const [authMode, setAuthMode] = useState<AuthMode>("login");
const [authMethod, setAuthMethod] = useState<AuthMethod>("account");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [betaCode, setBetaCode] = useState("");
const [authSubmitting, setAuthSubmitting] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
// 平台规则 gating:数据就绪(或兜底超时)后才渲染 EcommercePage
// 保证其模块求值时 platformRulesClient 缓存已填充,拿到 API 数据。
const [platformRulesReady, setPlatformRulesReady] = useState(false);
const [workspaceChrome, setWorkspaceChrome] = useState<WorkspaceChromeState>({
isToolPage: false,
});
useEffect(() => {
void loadDarkGreenTheme();
document.documentElement.dataset.theme = "dark";
document.documentElement.dataset.uiTheme = "dark-green";
document.documentElement.style.colorScheme = "dark";
document.body.classList.add("ecommerce-standalone-body");
return () => {
document.body.classList.remove("ecommerce-standalone-body");
};
}, []);
useEffect(() => {
const splash = document.getElementById("app-boot-splash");
if (splash) {
splash.style.opacity = "0";
const timer = window.setTimeout(() => splash.remove(), 350);
return () => window.clearTimeout(timer);
}
}, []);
useEffect(() => {
initNotificationPermission();
}, []);
// 启动 gating:预加载平台规则。preload 自带超时+fallback 一定会 resolve
// 另加 3s 兜底,避免极端情况下首屏久等(兜底放行后用 fallback,数据=正确值)。
useEffect(() => {
let settled = false;
const markReady = () => {
if (settled) return;
settled = true;
setPlatformRulesReady(true);
};
void preloadPlatformRules().then(markReady, markReady);
const fallbackTimer = window.setTimeout(markReady, 3_000);
return () => window.clearTimeout(fallbackTimer);
}, []);
useEffect(() => {
if (!session) return;
void flushPendingGenerationRecords();
}, [session]);
useEffect(() => {
const handleUnhandled = (event: ErrorEvent) => {
reportError(event.error || new Error(event.message), "unhandled");
};
const handleRejection = (event: PromiseRejectionEvent) => {
reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection");
};
window.addEventListener("error", handleUnhandled);
window.addEventListener("unhandledrejection", handleRejection);
return () => {
window.removeEventListener("error", handleUnhandled);
window.removeEventListener("unhandledrejection", handleRejection);
};
}, []);
const refreshUsage = useCallback(async () => {
try {
setUsage(await keyServerClient.getUsageSummary());
} catch {
// Usage is helpful but should not block the standalone generator.
}
}, [setUsage]);
const completeAuth = useCallback(
async (nextSession: WebUserSession) => {
setSession(nextSession);
setUserMaxConcurrency(nextSession.user.maxConcurrency);
setAuthOpen(false);
setAuthError(null);
await refreshUsage();
if (nextSession.user.email && !nextSession.user.emailVerified) {
toast.info("邮箱尚未验证,部分功能可能受限");
}
},
[refreshUsage, setSession],
);
const clearAuthenticatedState = useCallback(() => {
clearAllUserStorage();
clearSessionState();
setUserMaxConcurrency(null);
setUsage({
balanceCents: 0,
imageUsed: 0,
videoUsed: 0,
textUsed: 0,
source: "preview",
});
}, [clearSessionState, setUsage]);
useEffect(() => {
let cancelled = false;
const loadSession = async () => {
// 预加载公网配置(OSS base / logo URL),与 session 加载并行,不阻断启动。
void preloadPublicConfig();
try {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user.maxConcurrency);
if (nextSession) await refreshUsage();
} catch {
if (!cancelled) clearAuthenticatedState();
}
};
void loadSession();
return () => {
cancelled = true;
};
}, [clearAuthenticatedState, refreshUsage, setSession]);
useEffect(() => {
const handleSessionInvalid = (event: Event) => {
const detail = (event as CustomEvent<ServerSessionReplacedDetail>).detail;
clearAuthenticatedState();
setSessionNotice(detail?.message || "登录状态已失效,请重新登录。");
setAuthOpen(true);
};
window.addEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionInvalid);
window.addEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionInvalid);
return () => {
window.removeEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionInvalid);
window.removeEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionInvalid);
};
}, [clearAuthenticatedState]);
const openAuth = useCallback((mode: AuthMode = "login") => {
setAuthMode(mode);
setAuthError(null);
setSessionNotice(null);
setAuthOpen(true);
}, []);
const handleSubmitAuth = async () => {
if (!username.trim() || !password) {
setAuthError(authMethod === "email" ? "请输入邮箱和密码" : authMethod === "phone" ? "请输入手机号和验证码/密码" : "请输入用户名和密码");
return;
}
setAuthSubmitting(true);
setAuthError(null);
try {
const nextSession =
authMode === "login"
? await keyServerClient.login({ username, password })
: await keyServerClient.register({ username, password, betaCode });
await completeAuth(nextSession);
} catch (error) {
setAuthError(error instanceof Error ? error.message : "登录失败,请稍后重试。");
} finally {
setAuthSubmitting(false);
}
};
const handleLogout = () => {
setProfileMenuOpen(false);
setCurrentPage("workspace");
clearAuthenticatedState();
toast.info("已退出登录");
};
const balance = Math.max(usage.balanceCents, 0) / 100;
const handleOpenProfile = () => {
setProfileMenuOpen(false);
setCurrentPage("profile");
};
const handleOpenWorkspace = () => {
setProfileMenuOpen(false);
setCurrentPage("workspace");
};
const handleBugFeedback = () => {
setProfileMenuOpen(false);
toast.info("Bug 反馈入口已保留,后续可接入反馈页面。");
};
const shouldShowEcommerceTopbar = currentPage === "workspace" && !workspaceChrome.isToolPage;
return (
<div
className="ecommerce-standalone web-shell"
data-theme="dark"
data-ui-theme="dark-green"
data-view="ecommerce"
data-workspace-tool-page={workspaceChrome.isToolPage ? "true" : "false"}
>
{shouldShowEcommerceTopbar ? (
<Topbar
session={session}
usage={usage}
profileMenuOpen={profileMenuOpen}
onProfileMenuOpenChange={setProfileMenuOpen}
onOpenWorkspace={handleOpenWorkspace}
onOpenProfile={handleOpenProfile}
onOpenAuth={openAuth}
onLogout={handleLogout}
onBugFeedback={handleBugFeedback}
/>
) : null}
<main className="ecommerce-standalone__content">
{session ? (
<div
className="ecommerce-standalone__page ecommerce-standalone__page--profile"
hidden={currentPage !== "profile"}
>
<LocalProfilePage
session={session}
balance={balance}
imageCount={usage.imageUsed}
videoCount={usage.videoUsed}
onBack={handleOpenWorkspace}
onBugFeedback={handleBugFeedback}
onLogout={handleLogout}
/>
</div>
) : null}
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
<div
className="ecommerce-standalone__page ecommerce-standalone__page--workspace"
hidden={Boolean(session) && currentPage === "profile"}
>
<ErrorBoundary>
<Suspense
fallback={
<div className="page-loading-center">
<div className="page-loading-spinner" />
<span className="page-loading-center__text">...</span>
</div>
}
>
{platformRulesReady ? (
<EcommercePage
projects={[]}
isAuthenticated={Boolean(session)}
onWorkspaceChromeChange={setWorkspaceChrome}
onStartCreate={() => undefined}
onOpenProject={() => undefined}
onDeleteProject={() => undefined}
onImportWorkflow={() => undefined}
onCreateTask={() => undefined}
onRequireLogin={() => openAuth("login")}
initialTemplate={null}
onInitialTemplateConsumed={() => undefined}
/>
) : (
<div className="page-loading-center">
<div className="page-loading-spinner" />
<span className="page-loading-center__text">...</span>
</div>
)}
</Suspense>
</ErrorBoundary>
</div>
</main>
{authOpen ? (
<div className="ecommerce-auth-modal" role="dialog" aria-modal="true" aria-labelledby="ecommerce-auth-title">
<button
type="button"
className="ecommerce-auth-modal__scrim"
aria-label="关闭登录弹窗"
onClick={() => setAuthOpen(false)}
/>
<section className="ecommerce-auth-modal__panel">
<button
type="button"
className="ecommerce-auth-modal__close"
aria-label="关闭"
onClick={() => setAuthOpen(false)}
>
<CloseOutlined />
</button>
<span className="ecommerce-auth-modal__logo" aria-hidden="true">
<img src={getLogoUrl()} alt="" />
</span>
<h2 id="ecommerce-auth-title">{authMode === "login" ? "欢迎回来" : "创建账号"}</h2>
<p className="ecommerce-auth-modal__subtitle">{authMode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}</p>
{sessionNotice ? <p className="ecommerce-auth-modal__notice">{sessionNotice}</p> : null}
<div className="ecommerce-auth-modal__tabs" role="tablist" aria-label="账号操作">
<button type="button" className={authMode === "login" ? "is-active" : ""} onClick={() => { setAuthMode("login"); setAuthError(null); }}></button>
<button type="button" className={authMode === "register" ? "is-active" : ""} onClick={() => { setAuthMode("register"); setAuthError(null); }}></button>
</div>
<div className="ecommerce-auth-modal__methods" role="tablist" aria-label="登录方式">
<button type="button" className={authMethod === "account" ? "is-active" : ""} onClick={() => setAuthMethod("account")}><UserOutlined /></button>
<button type="button" className={authMethod === "email" ? "is-active" : ""} onClick={() => setAuthMethod("email")}><MailOutlined /></button>
<button type="button" className={authMethod === "phone" ? "is-active" : ""} onClick={() => setAuthMethod("phone")}><MobileOutlined /></button>
</div>
{authMode === "register" ? (
<label className="ecommerce-auth-field">
<span><SafetyOutlined /> / </span>
<input value={betaCode} onChange={(event) => setBetaCode(event.target.value)} placeholder="请输入企业邀请码或内测码" />
</label>
) : null}
{authMethod === "phone" ? (
<>
<label className="ecommerce-auth-field">
<span><MobileOutlined /></span>
<div className="ecommerce-auth-phone-row">
<b>+86</b>
<input value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="tel" placeholder="输入手机号" />
</div>
</label>
<label className="ecommerce-auth-field">
<span><SafetyOutlined /></span>
<div className="ecommerce-auth-code-row">
<input value={password} onChange={(event) => setPassword(event.target.value)} inputMode="numeric" placeholder="输入 6 位验证码" />
<button type="button" disabled></button>
</div>
</label>
</>
) : (
<>
<label className="ecommerce-auth-field">
<span>{authMethod === "email" ? <MailOutlined /> : <UserOutlined />}{authMode === "register" ? authMethod === "email" ? "邮箱" : "设置用户名" : authMethod === "email" ? "邮箱" : "用户名"}</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
inputMode={authMethod === "email" ? "email" : "text"}
placeholder={authMethod === "email" ? "输入邮箱地址" : authMode === "register" ? "输入用户名或邮箱" : "输入用户名或邮箱"}
/>
</label>
<label className="ecommerce-auth-field">
<span><LockOutlined />{authMode === "register" ? "设置密码" : "密码"}</span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
type="password"
autoComplete={authMode === "login" ? "current-password" : "new-password"}
placeholder={authMode === "register" ? "至少 6 位" : "输入密码"}
/>
</label>
</>
)}
{authError ? <p className="ecommerce-auth-modal__error" role="alert">{authError}</p> : null}
{authMode === "login" && authMethod !== "phone" ? <button type="button" className="ecommerce-auth-modal__forgot">?</button> : null}
<button type="button" className="ecommerce-auth-modal__submit" onClick={handleSubmitAuth} disabled={authSubmitting}>
{authSubmitting ? <LoadingOutlined /> : null}
{authMode === "login" ? "登录" : "注册"}
</button>
<p className="ecommerce-auth-modal__agreement">{authMode === "login" ? "登录" : "注册"} <a href="#"></a> <a href="#"></a></p>
<div className="ecommerce-auth-modal__divider"><span></span></div>
<button type="button" className="ecommerce-auth-modal__mobile-alt" onClick={() => setAuthMethod("phone")} aria-label="手机登录">
<MobileOutlined />
</button>
</section>
</div>
) : null}
<ToastContainer />
</div>
);
}
export default App;