Files
omniai-ds-code-package/src/App.tsx
T

593 lines
24 KiB
TypeScript

import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import {
BugOutlined,
CheckCircleFilled,
CloseOutlined,
HomeOutlined,
IdcardOutlined,
LockOutlined,
LoadingOutlined,
LoginOutlined,
LogoutOutlined,
MailOutlined,
MobileOutlined,
PictureOutlined,
SafetyOutlined,
UserOutlined,
VideoCameraOutlined,
WalletOutlined,
} from "@ant-design/icons";
import ErrorBoundary from "./components/ErrorBoundary";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import EcommercePage from "./features/ecommerce/EcommercePage";
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
import { ossAssets } from "./data/ossAssets";
import { keyServerClient } from "./api/keyServerClient";
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";
type AuthMode = "login" | "register";
type AuthMethod = "account" | "email" | "phone";
interface LocalProfilePageProps {
session: WebUserSession;
balance: number;
imageCount: number;
videoCount: number;
onBack: () => void;
onBugFeedback: () => void;
onLogout: () => void;
}
const profileWorks = [
{ title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" },
{ title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" },
{ title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" },
{ title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" },
{ title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" },
{ title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" },
{ title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" },
{ title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" },
];
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
const displayName = session.user.displayName || session.user.username || "用户";
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
const avatarUrl = session.user.avatarUrl;
return (
<span className={`local-user-avatar local-user-avatar--${size}`}>
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
</span>
);
}
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
const displayName = session.user.displayName || session.user.username || "用户";
const workCount = Math.max(imageCount + videoCount, profileWorks.length);
const projectCount = Math.max(1, Math.round(workCount / 18));
const assetCount = Math.max(1, Math.round(workCount / 20));
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">
{profileWorks.map((work) => (
<article key={`${work.title}-${work.time}`} className="local-profile-work-card">
<img src={work.image} alt="" />
<div>
<span>{work.type}</span>
<strong>{work.title}</strong>
<p>{work.desc}</p>
<em> · {work.time}</em>
</div>
</article>
))}
</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");
const [workspaceKey, setWorkspaceKey] = useState(0);
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();
}, []);
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 () => {
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 displayName = session?.user.displayName || session?.user.username || "用户";
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
const shownWorkCount = Math.max(actualWorkCount, profileWorks.length);
const avatarMenuStats = useMemo(
() => [
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
],
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
);
const handleOpenProfile = () => {
setProfileMenuOpen(false);
setCurrentPage("profile");
};
const handleOpenWorkspace = () => {
setProfileMenuOpen(false);
setCurrentPage("workspace");
setWorkspaceKey((k) => k + 1);
};
const handleBugFeedback = () => {
setProfileMenuOpen(false);
toast.info("Bug 反馈入口已保留,后续可接入反馈页面。");
};
return (
<div className="ecommerce-standalone web-shell" data-theme="dark" data-ui-theme="dark-green" data-view="ecommerce">
<header className="ecommerce-standalone__topbar">
<button type="button" className="ecommerce-standalone__brand" onClick={handleOpenWorkspace}>
<span className="ecommerce-standalone__logo" aria-hidden="true">
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
</span>
<strong>OmniAI </strong>
</button>
<div className="ecommerce-standalone__account">
{session ? (
<div className="ecommerce-profile-menu">
<span className="ecommerce-standalone__credits">
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)}
</span>
<button
type="button"
className="ecommerce-profile-menu__trigger"
onClick={() => setProfileMenuOpen((open) => !open)}
aria-haspopup="dialog"
aria-expanded={profileMenuOpen}
>
<LocalAvatar session={session} size="sm" />
<span>{displayName}</span>
</button>
{profileMenuOpen ? (
<>
<button
type="button"
className="ecommerce-profile-popover__backdrop"
aria-label="关闭账户信息"
onClick={() => setProfileMenuOpen(false)}
/>
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
<div className="ecommerce-profile-popover__head">
<LocalAvatar session={session} size="md" />
<div>
<strong>{displayName}</strong>
<span>{session.user.username}</span>
</div>
</div>
<dl className="ecommerce-profile-popover__stats">
{avatarMenuStats.map((item) => (
<div key={item.label}>
<dt>{item.icon}{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
<div className="ecommerce-profile-popover__actions">
<button type="button" className="is-primary" onClick={handleOpenProfile}>
<UserOutlined />
</button>
<button type="button" onClick={handleBugFeedback}>
<BugOutlined />
Bug
</button>
<button type="button" className="is-danger" onClick={handleLogout}>
<LogoutOutlined />
退
</button>
</div>
</section>
</>
) : null}
</div>
) : (
<button type="button" onClick={() => openAuth("login")}>
<LoginOutlined />
<span> / </span>
</button>
)}
</div>
</header>
<main className="ecommerce-standalone__content">
{currentPage === "profile" && session ? (
<LocalProfilePage
session={session}
balance={balance}
imageCount={usage.imageUsed}
videoCount={usage.videoUsed}
onBack={handleOpenWorkspace}
onBugFeedback={handleBugFeedback}
onLogout={handleLogout}
/>
) : (
<ErrorBoundary>
<Suspense
fallback={
<div className="page-loading-center">
<div className="page-loading-spinner" />
<span className="page-loading-center__text">...</span>
</div>
}
>
<EcommercePage
key={workspaceKey}
projects={[]}
isAuthenticated={Boolean(session)}
onStartCreate={() => undefined}
onOpenProject={() => undefined}
onDeleteProject={() => undefined}
onImportWorkflow={() => undefined}
onCreateTask={() => undefined}
onRequireLogin={() => openAuth("login")}
initialTemplate={null}
onInitialTemplateConsumed={() => undefined}
/>
</Suspense>
</ErrorBoundary>
)}
</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="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" 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;