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

1257 lines
52 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,
CheckOutlined,
CheckCircleFilled,
CloseOutlined,
DeleteOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlayCircleOutlined,
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";
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 || "资产";
}
function formatAssetType(type: SavedAssetItem["type"]): string {
const labels: Record<string, string> = {
character: "角色",
scene: "场景",
prop: "道具",
video: "视频",
image: "图像",
asset: "资产",
other: "素材",
};
return labels[type] || "素材";
}
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 [emailCode, setEmailCode] = useState("");
const [emailCooldown, setEmailCooldown] = useState(0);
const [isSendingEmail, setIsSendingEmail] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotStep, setForgotStep] = useState<"email" | "code" | "newPassword">("email");
const [forgotEmail, setForgotEmail] = useState("");
const [forgotCode, setForgotCode] = useState("");
const [forgotPassword, setForgotPassword] = useState("");
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);
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);
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 (emailCooldown <= 0) return;
const timer = window.setInterval(() => {
setEmailCooldown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [emailCooldown]);
const handleSendEmailCode = async (purpose: "register" | "login" | "reset" = "register") => {
const targetEmail = purpose === "reset" ? forgotEmail : email;
if (emailCooldown > 0 || !targetEmail.trim() || isSendingEmail) return;
if (purpose === "register" && !betaCode.trim()) {
setNotice("请输入企业邀请码 / 内测码后再获取验证码");
return;
}
setIsSendingEmail(true);
setNotice(null);
try {
const result = await keyServerClient.sendEmailCode(targetEmail, purpose, betaCode);
setEmailCooldown(result.cooldownSeconds || 60);
if (result.devCode) {
setNotice(`验证码已发送(开发模式: ${result.devCode}`);
} else {
setNotice("验证码已发送,请查收邮件");
}
} catch (error) {
setNotice(error instanceof Error ? error.message : "验证码发送失败");
} finally {
setIsSendingEmail(false);
}
};
const handleForgotPassword = async () => {
if (forgotStep === "email") {
if (!forgotEmail.trim()) { setNotice("请输入邮箱"); return; }
try {
await keyServerClient.forgotPassword({ email: forgotEmail });
setForgotStep("code");
setNotice("重置验证码已发送到您的邮箱");
await handleSendEmailCode("reset");
} catch (error) {
setNotice(error instanceof Error ? error.message : "发送失败");
}
} else if (forgotStep === "code") {
if (!forgotCode.trim()) { setNotice("请输入验证码"); return; }
setForgotStep("newPassword");
setNotice(null);
} else {
if (forgotPassword.length < 6) { setNotice("密码至少 6 位"); return; }
try {
const result = await keyServerClient.resetPassword({ email: forgotEmail, code: forgotCode, newPassword: forgotPassword });
setNotice(result.message || "密码重置成功,请重新登录");
setShowForgotPassword(false);
setForgotStep("email");
setForgotEmail("");
setForgotCode("");
setForgotPassword("");
setMode("login");
} catch (error) {
setNotice(error instanceof Error ? error.message : "重置失败");
}
}
};
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 "";
case "emailCode":
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;
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;
if (mode === "register") {
const codeErr = validateField("emailCode", emailCode);
if (codeErr) errors.emailCode = codeErr;
}
} 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, code: emailCode, 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);
};
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>
<p className="profile-page__empty-text">{text}</p>
<button type="button" className="profile-page__empty-btn" onClick={action}>
<PlusOutlined />
{actionLabel}
</button>
</div>
);
const renderCardPreview = (
url: string | null | undefined,
type: "image" | "video" | "project" | "asset",
label: string,
) => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
const placeholderIcon =
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
return (
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
{mediaUrl ? (
isVideoPreview ? (
<video src={mediaUrl} muted playsInline preload="metadata" />
) : (
<img src={mediaUrl} alt="" loading="lazy" />
)
) : (
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
)}
<span className="profile-page__media-badge">{label}</span>
</div>
);
};
const renderActivePanel = () => {
if (activePanel === "works") {
return visibleWorks.length ? (
<div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</div>
</article>
))}
</div>
</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 profile-page__media-card">
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
{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>{formatProfileDate(project.updatedAt)}</span>
</div>
</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 profile-page__media-card">
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{asset.name}</strong>
<span>{formatAssetStatus(asset.status)}</span>
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</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}
{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>
<div className="profile-page__account-card">
<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 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>
</>
) : (
<>
<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>
</>
)}
</div>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
退
</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>
</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>
</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>
</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" ? (
<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}
{showForgotPassword ? (
<div className="auth-page__forgot-box">
<p className="auth-page__forgot-title"></p>
{forgotStep === "email" ? (
<input value={forgotEmail} onChange={(e) => setForgotEmail(e.target.value)} placeholder="输入注册邮箱" type="email" className="auth-page__forgot-input" />
) : forgotStep === "code" ? (
<div className="auth-page__sms-row">
<input value={forgotCode} onChange={(e) => setForgotCode(e.target.value)} placeholder="输入验证码" maxLength={6} />
<button type="button" className="auth-page__sms-btn" disabled={emailCooldown > 0 || isSendingEmail} onClick={() => void handleSendEmailCode("reset")}>
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "重新发送"}
</button>
</div>
) : (
<input type="password" value={forgotPassword} onChange={(e) => setForgotPassword(e.target.value)} placeholder="输入新密码(至少 6 位)" className="auth-page__forgot-input" />
)}
<div className="auth-page__forgot-actions">
<button type="button" className="auth-page__forgot-cancel" onClick={() => { setShowForgotPassword(false); setForgotStep("email"); setForgotEmail(""); setForgotCode(""); setForgotPassword(""); setNotice(null); }}></button>
<button type="button" className="auth-page__forgot-confirm" onClick={() => void handleForgotPassword()}>
{forgotStep === "newPassword" ? "重置密码" : "下一步"}
</button>
</div>
</div>
) : null}
{!showForgotPassword && 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}
</label>
{mode === "login" ? (
<div className="auth-page__forgot">
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}></button>
</div>
) : null}
</>
) : null}
{!showForgotPassword && 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}
</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}
</label>
</>
) : null}
{!showForgotPassword && 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}
</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}
</label>
) : null}
</>
) : null}
{!showForgotPassword ? (
<>
{notice ? <p className="auth-page__notice">{notice}</p> : null}
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
<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>
</>
) : null}
</form>
</div>
</aside>
</section>
);
}
export default ProfilePage;