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

577 lines
23 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, 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 { flushPendingGenerationRecords } from "./api/generationRecordClient";
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";
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
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;
}
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, 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");
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 = actualWorkCount;
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");
};
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">
{session ? (
<div className="ecommerce-standalone__page" 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" 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>
}
>
<EcommercePage
projects={[]}
isAuthenticated={Boolean(session)}
onStartCreate={() => undefined}
onOpenProject={() => undefined}
onDeleteProject={() => undefined}
onImportWorkflow={() => undefined}
onCreateTask={() => undefined}
onRequireLogin={() => openAuth("login")}
initialTemplate={null}
onInitialTemplateConsumed={() => undefined}
/>
</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="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;