Files
omniai-web/src/features/profile/ProfilePage.tsx
T
2026-06-02 12:38:01 +08:00

1027 lines
42 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 {
CameraOutlined,
CheckCircleFilled,
DeleteOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlusOutlined,
SafetyOutlined,
ShareAltOutlined,
UserOutlined,
WechatOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { keyServerClient } from "../../api/keyServerClient";
import { isServerRequestError } from "../../api/serverConnection";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
interface ProfilePageProps {
session: WebUserSession | null;
usage: WebUsageSummary;
projects: WebProjectSummary[];
tasks: WebGenerationPreviewTask[];
pendingActionLabel?: string | null;
onLogin: (username: string, password: string) => Promise<void>;
onRegister: (username: string, password: string, betaCode: string) => Promise<void>;
onAuthComplete?: (session: WebUserSession) => Promise<void>;
onSessionChange?: (session: WebUserSession) => void;
onLogout: () => void;
onOpenWorkbench: () => void;
onOpenCommunity: () => void;
onDeleteProject?: (project: WebProjectSummary) => void;
}
type AuthTab = "password" | "email" | "phone" | "wechat";
type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4";
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
}
function readLocalProfileValue(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(profileStorageKey(userId, field)) || "";
} catch {
return "";
}
}
function writeLocalProfileValue(userId: string | number | undefined, field: "avatar" | "bio" | "background", value: string): void {
if (typeof window === "undefined") return;
try {
const key = profileStorageKey(userId, field);
if (value) {
window.localStorage.setItem(key, value);
} else {
window.localStorage.removeItem(key);
}
} catch {
// Local decoration should never block the account page.
}
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(file);
});
}
function formatProfileSyncError(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : String(error || "");
const message = raw.replace(/\s+/g, " ").trim();
if (!message) return fallback;
if (
/OSS PUT failed|UserDisable|AccessDenied|InvalidAccessKeyId|SignatureDoesNotMatch|<\?xml|<Error>/i.test(message)
) {
return "对象存储服务拒绝上传,请检查服务器 OSS 账号或权限配置。本地预览已保留。";
}
if (/403|forbidden/i.test(message)) {
return "当前账号没有上传权限,请检查登录状态或服务器权限。本地预览已保留。";
}
if (/413|payload too large|too large/i.test(message)) {
return "文件过大,暂时无法上传。本地预览已保留。";
}
return `${fallback}${message.slice(0, 90)}`;
}
function formatProfileLoadError(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : String(error || "");
const message = raw.replace(/\s+/g, " ").trim();
if (!message || /^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(message)) {
return fallback;
}
if (isServerRequestError(error)) {
if (error.status === 401 || error.status === 403) return "登录状态或权限不足,请重新登录后再试。";
if (error.status === 404) return `${fallback},服务端接口暂未开放或路径不存在。`;
if (error.status && error.status >= 500) return `${fallback},服务器暂时异常。`;
}
if (/not found/i.test(message)) return `${fallback},服务端接口暂未开放或路径不存在。`;
if (/forbidden|unauthorized/i.test(message)) return "登录状态或权限不足,请重新登录后再试。";
return `${fallback}${message.slice(0, 90)}`;
}
function mapAssetToSavedItem(asset: Awaited<ReturnType<typeof assetClient.list>>[number]): SavedAssetItem {
return {
...asset,
project: asset.sourceProjectId || "服务器资产库",
version: "Server",
ratio: String(asset.metadata?.ratio || "1:1"),
tags: asset.tags?.length ? asset.tags : ["服务器素材"],
thumbClass: asset.imageUrl ? "is-uploaded" : "is-station",
};
}
function ProfilePage({
session,
usage,
projects,
tasks,
pendingActionLabel,
onLogin,
onRegister,
onAuthComplete,
onSessionChange,
onLogout,
onOpenWorkbench,
onOpenCommunity,
onDeleteProject,
}: ProfilePageProps) {
const isLoggedIn = Boolean(session);
const userId = session?.user.id;
const displayName = session?.user.displayName || session?.user.username || "访客账号";
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const bannerInputRef = useRef<HTMLInputElement | null>(null);
const [mode, setMode] = useState<WebAuthMode>("login");
const [authTab, setAuthTab] = useState<AuthTab>("password");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [betaCode, setBetaCode] = useState("");
const [phone, setPhone] = useState("");
const [smsCode, setSmsCode] = useState("");
const [notice, setNotice] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [isSendingSms, setIsSendingSms] = useState(false);
const [wechatTicket, setWechatTicket] = useState<{ url?: string; state?: string; message?: string; configured?: boolean } | null>(null);
const [wechatStatus, setWechatStatus] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<ProfilePanel>("works");
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
const [savedAssets, setSavedAssets] = useState<SavedAssetItem[]>([]);
const [assetNotice, setAssetNotice] = useState<string | null>(null);
const [communityCases, setCommunityCases] = useState<ServerCommunityCase[]>([]);
const [communityNotice, setCommunityNotice] = useState<string | null>(null);
const [profileNotice, setProfileNotice] = useState<string | null>(null);
const [localAvatarUrl, setLocalAvatarUrl] = useState(() => session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
const [profileBio, setProfileBio] = useState(() => session?.user.bio || readLocalProfileValue(userId, "bio"));
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
const completedTasks = tasks.filter((task) => task.status === "completed");
const visibleWorks = completedTasks.length ? completedTasks : tasks.slice(0, 6);
const totalBalance = usage.balanceCents + (session?.user.enterpriseBalanceCents || 0);
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
useEffect(() => {
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
setProfileBio(session?.user.bio || readLocalProfileValue(userId, "bio"));
setBannerUrl(session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
}, [session?.user.avatarUrl, session?.user.backgroundUrl, session?.user.bio, userId]);
useEffect(() => {
if (!session) return;
setCommunityNotice(null);
setAssetNotice(null);
let cancelled = false;
void Promise.allSettled([assetClient.list(), communityClient.listMyCases()]).then((results) => {
if (cancelled) return;
const [assetResult, communityResult] = results;
if (assetResult.status === "fulfilled") {
setSavedAssets(assetResult.value.map(mapAssetToSavedItem));
} else {
setSavedAssets([]);
setAssetNotice(formatProfileLoadError(assetResult.reason, "资产记录暂时不可用"));
}
if (communityResult.status === "fulfilled") {
setCommunityCases(communityResult.value);
} else {
setCommunityCases([]);
setCommunityNotice(formatProfileLoadError(communityResult.reason, "社区记录暂时不可用"));
}
});
return () => {
cancelled = true;
};
}, [session]);
useEffect(() => {
if (!session || activePanel !== "assets") return;
setAssetNotice(null);
assetClient
.list()
.then((items) => setSavedAssets(items.map(mapAssetToSavedItem)))
.catch((error) => {
setSavedAssets([]);
setAssetNotice(formatProfileLoadError(error, "资产记录暂时不可用"));
});
}, [activePanel, session]);
useEffect(() => {
if (smsCooldown <= 0) return;
const timer = window.setInterval(() => {
setSmsCooldown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [smsCooldown]);
useEffect(() => {
if (authTab !== "wechat" || isLoggedIn) return;
let cancelled = false;
let pollTimer: number | undefined;
const startWechatLogin = async () => {
setWechatStatus("正在创建微信登录二维码...");
setWechatTicket(null);
try {
const ticket = await keyServerClient.getWechatLoginTicket();
if (cancelled) return;
setWechatTicket(ticket);
if (!ticket.configured || !ticket.url || !ticket.state) {
setWechatStatus(ticket.message || "微信登录暂未配置");
return;
}
setWechatStatus("请使用微信扫码登录");
pollTimer = window.setInterval(() => {
void keyServerClient
.getWechatLoginSession(ticket.state!)
.then(async (result) => {
if (cancelled) return;
if (result.status === "completed" && result.session) {
if (pollTimer) window.clearInterval(pollTimer);
setWechatStatus("微信登录成功,正在进入工作台...");
await onAuthComplete?.(result.session);
} else if (result.status !== "pending") {
if (pollTimer) window.clearInterval(pollTimer);
setWechatStatus(result.error || "微信登录已失效,请刷新二维码");
}
})
.catch((error) => {
if (!cancelled) setWechatStatus(error instanceof Error ? error.message : "微信登录状态查询失败");
});
}, 2000);
} catch (error) {
if (!cancelled) setWechatStatus(error instanceof Error ? error.message : "微信登录二维码创建失败");
}
};
void startWechatLogin();
return () => {
cancelled = true;
if (pollTimer) window.clearInterval(pollTimer);
};
}, [authTab, isLoggedIn, onAuthComplete]);
const handleSendSms = async () => {
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
if (mode === "register" && !betaCode.trim()) {
setNotice("请输入企业邀请码 / 内测码后再获取验证码");
return;
}
setIsSendingSms(true);
setNotice(null);
try {
const result = await keyServerClient.sendSmsCode(phone, mode === "login" ? "login" : "register", betaCode);
setSmsCooldown(result.cooldownSeconds || 60);
setNotice("验证码已发送,请查收短信");
} catch (error) {
setNotice(error instanceof Error ? error.message : "验证码发送失败");
} finally {
setIsSendingSms(false);
}
};
const validateField = (field: string, value: string): string => {
switch (field) {
case "username":
if (!value.trim()) return "请输入用户名";
if (mode === "register" && value.trim().length < 2) return "用户名至少 2 个字符";
return "";
case "password":
if (!value) return "请输入密码";
if (mode === "register" && value.length < 6) return "密码至少 6 位";
return "";
case "email":
if (!value.trim()) return "请输入邮箱";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "邮箱格式不正确";
return "";
case "phone":
if (!value.trim()) return "请输入手机号";
if (!/^1[3-9]\d{9}$/.test(value)) return "手机号格式不正确";
return "";
case "betaCode":
if (!value.trim()) return "请输入邀请码";
return "";
case "smsCode":
if (!value.trim()) return "请输入验证码";
if (value.length !== 6) return "验证码为 6 位数字";
return "";
default:
return "";
}
};
const handleFieldBlur = (field: string, value: string) => {
const error = validateField(field, value);
setFieldErrors((prev) => ({ ...prev, [field]: error }));
};
const clearFieldError = (field: string) => {
if (fieldErrors[field]) {
setFieldErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting || authTab === "wechat") return;
const errors: Record<string, string> = {};
if (mode === "register") {
const betaErr = validateField("betaCode", betaCode);
if (betaErr) errors.betaCode = betaErr;
}
if (authTab === "phone") {
const phoneErr = validateField("phone", phone);
if (phoneErr) errors.phone = phoneErr;
const smsErr = validateField("smsCode", smsCode);
if (smsErr) errors.smsCode = smsErr;
if (mode === "register") {
const pwErr = validateField("password", password);
if (pwErr) errors.password = pwErr;
}
} else if (authTab === "email") {
const emailErr = validateField("email", email);
if (emailErr) errors.email = emailErr;
const pwErr = validateField("password", password);
if (pwErr) errors.password = pwErr;
} else {
const userErr = validateField("username", username);
if (userErr) errors.username = userErr;
const pwErr = validateField("password", password);
if (pwErr) errors.password = pwErr;
}
if (Object.values(errors).some(Boolean)) {
setFieldErrors(errors);
return;
}
setIsSubmitting(true);
setNotice(null);
try {
if (authTab === "phone") {
const nextSession =
mode === "login"
? await keyServerClient.loginPhone({ phone, code: smsCode })
: await keyServerClient.registerPhone({ phone, code: smsCode, password, betaCode });
await onAuthComplete?.(nextSession);
} else if (authTab === "email") {
const nextSession =
mode === "login"
? await keyServerClient.loginEmail({ email, password })
: await keyServerClient.registerEmail({ email, password, username: username.trim() || undefined, betaCode });
await onAuthComplete?.(nextSession);
} else if (mode === "login") {
await onLogin(username.trim(), password);
} else {
await onRegister(username.trim(), password, betaCode);
}
} catch (error) {
setNotice(error instanceof Error ? error.message : "操作失败,请稍后重试");
} finally {
setIsSubmitting(false);
}
};
const updateProfileUser = (patch: Partial<WebUserSession["user"]>) => {
if (!session) return;
const nextUser = { ...session.user, ...patch };
const nextSession = keyServerClient.updateStoredSessionUser(nextUser) || { ...session, user: nextUser };
onSessionChange?.(nextSession);
};
const syncProfilePatch = async (patch: Parameters<typeof keyServerClient.updateProfile>[0]) => {
if (!session) return;
try {
const user = await keyServerClient.updateProfile(patch);
updateProfileUser(user);
setProfileNotice(null);
} catch (error) {
setProfileNotice(formatProfileSyncError(error, "个人资料同步失败"));
}
};
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !file.type.startsWith("image/")) return;
const dataUrl = await readFileAsDataUrl(file);
try {
const uploaded = await aiGenerationClient.uploadAsset({
dataUrl,
name: file.name,
mimeType: file.type,
scope: "profile-avatar",
});
const nextUrl = uploaded.url || dataUrl;
setLocalAvatarUrl(nextUrl);
writeLocalProfileValue(userId, "avatar", nextUrl);
updateProfileUser({ avatarUrl: nextUrl });
await syncProfilePatch({ avatarOssKey: uploaded.ossKey || null, avatarUrl: uploaded.url || nextUrl });
} catch (error) {
setLocalAvatarUrl(dataUrl);
writeLocalProfileValue(userId, "avatar", dataUrl);
updateProfileUser({ avatarUrl: dataUrl });
setProfileNotice(formatProfileSyncError(error, "头像上传失败,已先在本地预览"));
}
};
const handleBannerUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !file.type.startsWith("image/")) return;
const dataUrl = await readFileAsDataUrl(file);
try {
const uploaded = await aiGenerationClient.uploadAsset({
dataUrl,
name: file.name,
mimeType: file.type,
scope: "profile-background",
});
const nextUrl = uploaded.url || dataUrl;
setBannerUrl(nextUrl);
writeLocalProfileValue(userId, "background", nextUrl);
updateProfileUser({ backgroundUrl: nextUrl });
await syncProfilePatch({ backgroundUrl: nextUrl, profileBackgroundUrl: nextUrl });
} catch (error) {
setBannerUrl(dataUrl);
writeLocalProfileValue(userId, "background", dataUrl);
updateProfileUser({ backgroundUrl: dataUrl });
setProfileNotice(formatProfileSyncError(error, "背景上传失败,已先在本地预览"));
}
};
const handleBioBlur = () => {
const nextBio = profileBio.trim();
writeLocalProfileValue(userId, "bio", nextBio);
updateProfileUser({ bio: nextBio || null });
void syncProfilePatch({ bio: nextBio || null });
};
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
<div className="profile-page__empty-state">
<p className="profile-page__empty-text">{text}</p>
<button type="button" className="profile-page__empty-btn" onClick={action}>
<PlusOutlined />
{actionLabel}
</button>
</div>
);
const renderActivePanel = () => {
if (activePanel === "works") {
return visibleWorks.length ? (
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
<span>{task.type}</span>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{task.status}</span>
<span>{task.createdAt}</span>
</div>
</article>
))}
</div>
) : (
renderEmptyState("向全世界展示你最得意的创作。", "开始创作", onOpenWorkbench)
);
}
if (activePanel === "projects") {
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
<span>{project.updatedAt}</span>
{onDeleteProject ? (
<button
type="button"
className="profile-page__delete-project"
aria-label={`删除项目 ${project.name}`}
onClick={() => onDeleteProject(project)}
>
<DeleteOutlined />
</button>
) : null}
</div>
<p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span>
</div>
</article>
))}
</div>
) : (
renderEmptyState("还没有同步到服务器的项目。", "进入工作台", onOpenWorkbench)
);
}
if (activePanel === "assets") {
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{asset.name}</strong>
<span>{asset.status}</span>
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{asset.type}</span>
<span>{asset.updatedAt}</span>
</div>
</article>
))}
</div>
) : (
renderEmptyState(assetNotice || "服务器资产库暂无内容。", "去工作台生成", onOpenWorkbench)
);
}
return communityCases.length ? (
<div className="profile-page__review-list">
{communityCases.map((item) => (
<div key={item.id} className="profile-page__review-item">
{item.coverUrl ? <img src={item.coverUrl} alt="" /> : <span className="profile-page__review-thumb" />}
<div>
<strong>{item.title}</strong>
<span>
{item.status === "approved" ? "已通过" : item.status === "rejected" ? "未通过" : "审核中"}
</span>
<small>{item.copyCount} </small>
</div>
</div>
))}
</div>
) : (
renderEmptyState(communityNotice || "还没有发布到社区的内容。", "去社区发布", onOpenCommunity)
);
};
if (isLoggedIn) {
return (
<section className="profile-page profile-page--dashboard page-motion">
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
<header
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<CameraOutlined />
</button>
<div className="profile-page__banner-overlay" />
</header>
<div className="profile-page__body">
<aside className="profile-page__sidebar">
<div className="profile-page__sidebar-head">
<div className="profile-page__avatar-ring">
{avatarUrl ? (
<img className="profile-page__avatar" src={avatarUrl} alt="" />
) : (
<span className="profile-page__avatar">{avatarLabel}</span>
)}
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像">
<CameraOutlined />
</button>
<span className="profile-page__avatar-badge">
<CheckCircleFilled />
</span>
</div>
<strong className="profile-page__username">{displayName}</strong>
<textarea
className="profile-page__bio"
value={profileBio}
onChange={(event) => setProfileBio(event.target.value)}
onBlur={handleBioBlur}
placeholder={displayedBio}
rows={2}
maxLength={80}
/>
{profileNotice ? <span className="profile-page__sync-notice">{profileNotice}</span> : null}
</div>
<div className="profile-page__counts">
<div className="profile-page__count">
<strong>{projects.length}</strong>
<span></span>
</div>
<div className="profile-page__count">
<strong>{completedTasks.length}</strong>
<span></span>
</div>
<div className="profile-page__count">
<strong>{savedAssets.length}</strong>
<span></span>
</div>
</div>
<button type="button" className="profile-page__share-btn">
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn" onClick={onOpenWorkbench}>
</button>
<button type="button" className="profile-page__share-btn" onClick={onOpenCommunity}>
</button>
<button type="button" className="profile-page__share-btn" onClick={onLogout}>
退
</button>
</aside>
<main className="profile-page__main">
<div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
</button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
</button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
</button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
</button>
</div>
<div className="profile-page__section">
<span className="profile-page__section-label">
{activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
{renderActivePanel()}
</div>
<div className="profile-page__section">
<div className="profile-page__list-bar">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span>{displayName}</span>
<span>{(usage.balanceCents / 100).toFixed(2)}</span>
</>
) : (
<>
<span>{tasks.length}</span>
<span>{completedTasks.length}</span>
</>
)}
</div>
</div>
</main>
</div>
</section>
);
}
return (
<section className="auth-page page-motion">
<div className="auth-page__showcase">
<div className="auth-page__video-wrap">
<video className="auth-page__video" autoPlay muted loop playsInline poster="">
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
</video>
<div className="auth-page__video-overlay">
<h1 className="auth-page__brand">OmniAI</h1>
<p className="auth-page__tagline"></p>
<div className="auth-page__features">
<span>AI </span>
<span>AI </span>
<span>AI </span>
</div>
</div>
</div>
</div>
<aside className="auth-page__form-panel">
<div className="auth-page__form-inner">
<div className="auth-page__form-head">
<span className="auth-page__logo">
<img src={AUTH_LOGO_URL} alt="OmniAI" />
</span>
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
<p className="auth-page__subtitle">
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
</p>
</div>
<div className="auth-page__mode-tabs">
<button
type="button"
className={mode === "login" ? "is-active" : ""}
onClick={() => {
setMode("login");
setNotice(null);
setFieldErrors({});
}}
>
</button>
<button
type="button"
className={mode === "register" ? "is-active" : ""}
onClick={() => {
setMode("register");
setNotice(null);
setFieldErrors({});
}}
>
</button>
</div>
<div className="auth-page__auth-tabs">
<button type="button" className={authTab === "password" ? "is-active" : ""} onClick={() => { setAuthTab("password"); setFieldErrors({}); }}>
<UserOutlined />
</button>
<button type="button" className={authTab === "email" ? "is-active" : ""} onClick={() => { setAuthTab("email"); setFieldErrors({}); }}>
<MailOutlined />
</button>
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
<MobileOutlined />
</button>
<button type="button" className={authTab === "wechat" ? "is-active" : ""} onClick={() => { setAuthTab("wechat"); setFieldErrors({}); }}>
<WechatOutlined />
</button>
</div>
{pendingActionLabel ? (
<div className="auth-page__pending">
<span></span>
<strong>{pendingActionLabel}</strong>
</div>
) : null}
<form className="auth-page__form" onSubmit={(event) => void handleSubmit(event)}>
{mode === "register" && authTab !== "wechat" ? (
<label className={`auth-page__field${fieldErrors.betaCode ? " auth-page__field--error" : ""}`}>
<span>
<SafetyOutlined /> /
</span>
<input
value={betaCode}
onChange={(event) => { setBetaCode(event.target.value); clearFieldError("betaCode"); }}
onBlur={() => handleFieldBlur("betaCode", betaCode)}
placeholder="请输入企业邀请码或内测码"
autoComplete="one-time-code"
/>
{fieldErrors.betaCode ? <span className="auth-page__field-error">{fieldErrors.betaCode}</span> : null}
</label>
) : null}
{authTab === "password" ? (
<>
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
<span>
<UserOutlined /> {mode === "register" ? "设置用户名" : "用户名"}
</span>
<input
value={username}
onChange={(event) => { setUsername(event.target.value); clearFieldError("username"); }}
onBlur={() => handleFieldBlur("username", username)}
placeholder="输入用户名或邮箱"
autoComplete="username"
/>
{fieldErrors.username ? <span className="auth-page__field-error">{fieldErrors.username}</span> : null}
</label>
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
<span>
<LockOutlined /> {mode === "register" ? "设置密码" : "密码"}
</span>
<input
type="password"
value={password}
onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }}
onBlur={() => handleFieldBlur("password", password)}
placeholder={mode === "register" ? "至少 8 位,含字母和数字" : "输入密码"}
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
</label>
{mode === "login" ? (
<div className="auth-page__forgot">
<button type="button"></button>
</div>
) : null}
</>
) : null}
{authTab === "email" ? (
<>
{mode === "register" ? (
<label className="auth-page__field">
<span>
<UserOutlined />
</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="可选,不填将自动生成"
autoComplete="username"
/>
</label>
) : null}
<label className={`auth-page__field${fieldErrors.email ? " auth-page__field--error" : ""}`}>
<span>
<MailOutlined />
</span>
<input
type="email"
value={email}
onChange={(event) => { setEmail(event.target.value); clearFieldError("email"); }}
onBlur={() => handleFieldBlur("email", email)}
placeholder="输入邮箱地址"
autoComplete="email"
/>
{fieldErrors.email ? <span className="auth-page__field-error">{fieldErrors.email}</span> : null}
</label>
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
<span>
<LockOutlined /> {mode === "register" ? "设置密码" : "密码"}
</span>
<input
type="password"
value={password}
onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }}
onBlur={() => handleFieldBlur("password", password)}
placeholder={mode === "register" ? "至少 6 位" : "输入密码"}
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
</label>
</>
) : null}
{authTab === "phone" ? (
<>
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
<span>
<PhoneOutlined />
</span>
<div className="auth-page__phone-row">
<span className="auth-page__phone-prefix">+86</span>
<input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" />
</div>
{fieldErrors.phone ? <span className="auth-page__field-error">{fieldErrors.phone}</span> : null}
</label>
<label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
<span>
<SafetyOutlined />
</span>
<div className="auth-page__sms-row">
<input value={smsCode} onChange={(event) => { setSmsCode(event.target.value); clearFieldError("smsCode"); }} onBlur={() => handleFieldBlur("smsCode", smsCode)} placeholder="输入 6 位验证码" maxLength={6} />
<button
type="button"
className="auth-page__sms-btn"
disabled={smsCooldown > 0 || !phone.trim() || isSendingSms || (mode === "register" && !betaCode.trim())}
onClick={() => void handleSendSms()}
>
{isSendingSms ? "发送中" : smsCooldown > 0 ? `${smsCooldown}s` : "获取验证码"}
</button>
</div>
{fieldErrors.smsCode ? <span className="auth-page__field-error">{fieldErrors.smsCode}</span> : null}
</label>
{mode === "register" ? (
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
<span>
<LockOutlined />
</span>
<input type="password" value={password} onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }} onBlur={() => handleFieldBlur("password", password)} placeholder="至少 6 位" autoComplete="new-password" />
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
</label>
) : null}
</>
) : null}
{authTab === "wechat" ? (
<div className="auth-page__wechat-qr">
<div className="auth-page__qr-placeholder">
{wechatTicket?.url ? (
<>
<iframe className="auth-page__wechat-frame" title="微信扫码登录" src={wechatTicket.url} />
<a className="auth-page__wechat-link" href={wechatTicket.url} target="_blank" rel="noreferrer">
</a>
</>
) : (
<>
<WechatOutlined />
<span></span>
<p>{wechatStatus || "正在准备微信登录"}</p>
</>
)}
</div>
{wechatStatus ? <p className="auth-page__wechat-status">{wechatStatus}</p> : null}
</div>
) : null}
{notice ? <p className="auth-page__notice">{notice}</p> : null}
{authTab !== "wechat" ? (
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
) : null}
<div className="auth-page__agreement">
<span>
{mode === "register" ? "注册即表示同意" : "登录即表示同意"}
<a href="#"></a><a href="#"></a>
</span>
</div>
<div className="auth-page__divider">
<span></span>
</div>
<div className="auth-page__social">
<button type="button" className="auth-page__social-btn" title="微信登录" onClick={() => setAuthTab("wechat")}>
<WechatOutlined />
</button>
<button type="button" className="auth-page__social-btn" title="手机号登录" onClick={() => setAuthTab("phone")}>
<MobileOutlined />
</button>
</div>
</form>
</div>
</aside>
</section>
);
}
export default ProfilePage;