Files
omniai-web/src/features/profile/ProfilePage.tsx
T

1100 lines
45 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
CameraOutlined,
CheckOutlined,
2026-06-02 12:38:01 +08:00
CheckCircleFilled,
CloseOutlined,
2026-06-02 12:38:01 +08:00
DeleteOutlined,
EditOutlined,
2026-06-02 12:38:01 +08:00
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlusOutlined,
SafetyOutlined,
ShareAltOutlined,
UserOutlined,
} 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";
2026-06-02 12:38:01 +08:00
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 formatProfileDate(value: string | null | undefined): string {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatTaskType(type: WebGenerationPreviewTask["type"]): string {
const labels: Record<WebGenerationPreviewTask["type"], string> = {
image: "图像",
video: "视频",
agent: "智能体",
"digital-human": "数字人",
"character-mix": "角色融合",
};
return labels[type] || type;
}
function formatTaskStatus(status: WebGenerationPreviewTask["status"]): string {
const labels: Record<WebGenerationPreviewTask["status"], string> = {
queued: "排队中",
running: "生成中",
completed: "已完成",
failed: "失败",
};
return labels[status] || status;
}
function formatAssetStatus(status: string | undefined): string {
const normalized = String(status || "").toLowerCase();
if (normalized === "completed" || normalized === "ready" || normalized === "success") return "可用";
if (normalized === "running" || normalized === "processing") return "处理中";
if (normalized === "failed" || normalized === "error") return "失败";
return status || "资产";
}
2026-06-02 12:38:01 +08:00
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 [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 [isBioEditing, setIsBioEditing] = useState(false);
const [bioEditBackup, setBioEditBackup] = useState("");
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
2026-06-02 12:38:01 +08:00
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() || "这个人还没有填写个性签名";
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
2026-06-02 12:38:01 +08:00
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]);
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) return;
2026-06-02 12:38:01 +08:00
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 startBioEdit = () => {
setBioEditBackup(profileBio);
setBioStatusNotice(null);
setIsBioEditing(true);
};
const confirmBioEdit = () => {
handleBioBlur();
setIsBioEditing(false);
setBioStatusNotice("个性签名已保存");
};
const cancelBioEdit = () => {
setProfileBio(bioEditBackup);
setIsBioEditing(false);
setBioStatusNotice(null);
};
2026-06-02 12:38:01 +08:00
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
<div className="profile-page__empty-state">
<span className="profile-page__empty-mark" aria-hidden="true">
<PlusOutlined />
</span>
2026-06-02 12:38:01 +08:00
<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>{formatTaskType(task.type)}</span>
2026-06-02 12:38:01 +08:00
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
2026-06-02 12:38:01 +08:00
</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>{formatProfileDate(project.updatedAt)}</span>
2026-06-02 12:38:01 +08:00
{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>{formatAssetStatus(asset.status)}</span>
2026-06-02 12:38:01 +08:00
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{asset.type}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
2026-06-02 12:38:01 +08:00
</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>
{isBioEditing ? (
<div className="profile-page__bio-editor">
<textarea
className="profile-page__bio"
value={profileBio}
onChange={(event) => setProfileBio(event.target.value)}
placeholder="填写一句个人签名"
rows={2}
maxLength={80}
autoFocus
/>
<div className="profile-page__bio-actions">
<button type="button" className="profile-page__bio-action profile-page__bio-action--save" onClick={confirmBioEdit}>
<CheckOutlined />
</button>
<button type="button" className="profile-page__bio-action" onClick={cancelBioEdit}>
<CloseOutlined />
</button>
</div>
</div>
) : (
<button
type="button"
className={`profile-page__bio-display${profileBio.trim() ? "" : " is-empty"}`}
onClick={startBioEdit}
>
<span>{displayedBio}</span>
<EditOutlined className="profile-page__bio-edit-icon" />
</button>
)}
{bioStatusNotice ? <span className="profile-page__bio-status">{bioStatusNotice}</span> : null}
2026-06-02 12:38:01 +08:00
{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 profile-page__share-btn--plan">
2026-06-02 12:38:01 +08:00
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
2026-06-02 12:38:01 +08:00
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
2026-06-02 12:38:01 +08:00
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
2026-06-02 12:38:01 +08:00
退
</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 className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
2026-06-02 12:38:01 +08:00
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
2026-06-02 12:38:01 +08:00
</>
)}
</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">
<div className="auth-page__showcase-content">
<div className="auth-page__brand-row">
<h1 className="auth-page__brand">OmniAI</h1>
</div>
<p className="auth-page__tagline"></p>
<div className="auth-page__features">
<span>AI </span>
<span>AI </span>
<span>AI </span>
</div>
<div className="auth-page__showcase-stats" aria-label="平台能力">
<span>
<strong>Studio</strong>
</span>
<span>
<strong>Assets</strong>
</span>
<span>
<strong>Team</strong>
</span>
</div>
2026-06-02 12:38:01 +08:00
</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 />
<span className="auth-page__tab-label-short"></span>
2026-06-02 12:38:01 +08:00
</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" ? (
2026-06-02 12:38:01 +08:00
<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}
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
2026-06-02 12:38:01 +08:00
</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}
{emailLooksValid && !fieldErrors.email ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
2026-06-02 12:38:01 +08:00
</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}
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
2026-06-02 12:38:01 +08:00
</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}
{phoneLooksValid && !fieldErrors.phone ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
2026-06-02 12:38:01 +08:00
</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}
{passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
2026-06-02 12:38:01 +08:00
</label>
) : null}
</>
) : null}
{notice ? <p className="auth-page__notice">{notice}</p> : null}
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
2026-06-02 12:38:01 +08:00
<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("phone")}>
<MobileOutlined />
</button>
</div>
</form>
</div>
</aside>
</section>
);
}
export default ProfilePage;