Files
omniai-web/src/features/profile/ProfilePage.tsx
T
ludan 4530058648 feat: 工具盒视觉重设计 + 个人中心详情弹窗
本次提交对全部工具入口页(MorePage)进行了全面的信息架构和视觉升级,并为个人中心新增卡片点击详情弹窗。

## 工具盒(MorePage)重设计
- 工具卡片增加 useCase 使用场景说明和 tags 标签行,帮助用户快速理解每个工具的适用场景
- 核心工具(Featured)卡片新增 kicker 标题、steps 操作步骤、outcome 产出说明,强化工作流引导
- 新增 ToolComparePanel 组件,为每个工具展示 Before/After 对比示意舞台
- 分类筛选按钮新增计数徽章,展示每个分类下的工具数量
- 页面头部新增 eyebrow(AI Tool Hub)+ 工具概览统计信息
- 最近使用区域增加分类标签副标题
- 空分类场景新增引导式空状态面板
- 全面补充 aria-label 和无障碍键盘支持

## 个人中心详情弹窗
- 新增 ProfileDetailSelection 类型和 openDetailSelection/closeDetailSelection 流程
- 使用 createPortal 渲染详情弹窗,支持代表作和资产两种详情视图
- 弹窗内支持媒体预览(图片/视频)、元数据展示、下载和删除操作
- 列表卡片(代表作/项目/资产)改为 interactive-card,支持键盘 Enter/Space 激活
- 删除项目按钮增加 event.stopPropagation 防止冒泡触发卡片点击
- 弹窗打开时锁定 body 滚动,Esc 键关闭

## App.tsx 适配
- 传递 setTasks 给 ProfilePage,支持代表作移除操作
- 传递 onOpenProject 回调,支持从个人中心打开项目

## CSS 样式升级
- more.css: 全面重设页头布局(grid 三栏)、筛选胶囊、核心卡片 Before/After 舞台、步骤条、响应式适配
- profile.css: 新增详情弹窗 overlay/panel/preview 布局、交互卡片 hover/focus 状态
- dark-green.css: 工具盒与详情弹窗的深绿主题样式约 780 行
2026-06-07 11:42:00 +08:00

1535 lines
63 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,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlayCircleOutlined,
PlusOutlined,
SafetyOutlined,
ShareAltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
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 { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
import { downloadResultAsset } from "../workbench/workbenchDownload";
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;
onOpenProject?: (project: WebProjectSummary) => void;
onRemoveWork?: (task: WebGenerationPreviewTask) => void;
}
type AuthTab = "password" | "email" | "phone";
type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
type ProfileDetailSelection =
| { kind: "work"; item: WebGenerationPreviewTask }
| { kind: "asset"; item: SavedAssetItem };
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = ossAssets.brand.logo;
const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
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,
onOpenProject,
onRemoveWork,
}: 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 [detailSelection, setDetailSelection] = useState<ProfileDetailSelection | null>(null);
const [detailNotice, setDetailNotice] = useState<string | null>(null);
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
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 activePanelTitle =
activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核";
const activePanelDescription =
activePanel === "works"
? "最近完成的高质量生成内容"
: activePanel === "projects"
? "云端同步的创作项目"
: activePanel === "assets"
? "可复用的图片、视频与素材"
: "已提交社区的案例状态";
const activePanelCount =
activePanel === "works"
? visibleWorks.length
: activePanel === "projects"
? projects.length
: activePanel === "assets"
? savedAssets.length
: communityCases.length;
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 handleInteractiveCardKeyDown = (event: KeyboardEvent<HTMLElement>, action: () => void) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
action();
};
const openDetailSelection = (selection: ProfileDetailSelection) => {
setDetailNotice(null);
setIsDeletingDetail(false);
setIsDownloadingDetail(false);
setDetailSelection(selection);
};
const closeDetailSelection = () => {
if (isDeletingDetail || isDownloadingDetail) return;
setDetailSelection(null);
setDetailNotice(null);
};
useEffect(() => {
if (!detailSelection) return undefined;
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") closeDetailSelection();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [detailSelection, isDeletingDetail, isDownloadingDetail]);
useEffect(() => {
if (!detailSelection || typeof document === "undefined") return undefined;
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousRootOverscroll = documentElement.style.overscrollBehavior;
body.style.overflow = "hidden";
documentElement.style.overscrollBehavior = "contain";
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overscrollBehavior = previousRootOverscroll;
};
}, [detailSelection]);
const handleDownloadSelectedDetail = async () => {
if (!detailSelection || isDownloadingDetail) return;
const isWork = detailSelection.kind === "work";
const item = detailSelection.item;
const url = isWork ? item.outputUrl : item.imageUrl || item.url || "";
if (!url) {
setDetailNotice("暂无可下载的媒体文件");
return;
}
const isVideo = isWork ? item.type === "video" : item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
const taskId = isWork ? item.id : item.sourceTaskId || undefined;
const name = isWork ? item.title : item.name;
setIsDownloadingDetail(true);
setDetailNotice("正在准备下载...");
try {
const status = await downloadResultAsset(url, name, isVideo, taskId);
setDetailNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setDetailNotice("已取消下载");
} else {
setDetailNotice(error instanceof Error ? error.message : "下载失败,请稍后重试");
}
} finally {
setIsDownloadingDetail(false);
}
};
const handleDeleteSelectedDetail = async () => {
if (!detailSelection || isDeletingDetail) return;
if (detailSelection.kind === "work") {
onRemoveWork?.(detailSelection.item);
setDetailNotice("已从当前代表作列表移除");
setDetailSelection(null);
return;
}
setIsDeletingDetail(true);
setDetailNotice(null);
try {
await assetClient.delete(detailSelection.item.id, { cleanupUserData: true });
setSavedAssets((current) => current.filter((asset) => asset.id !== detailSelection.item.id));
setDetailSelection(null);
setAssetNotice(`已删除 ${detailSelection.item.name}`);
} catch (error) {
setDetailNotice(formatProfileLoadError(error, "资产删除失败"));
} finally {
setIsDeletingDetail(false);
}
};
const renderDetailMedia = (url: string | null | undefined, type: "image" | "video" | "asset") => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(mediaUrl);
if (!mediaUrl) {
return (
<div className="profile-page__detail-placeholder">
{type === "video" ? <PlayCircleOutlined /> : <FileImageOutlined />}
<span></span>
</div>
);
}
return isVideoPreview ? (
<video className="profile-page__detail-media" src={mediaUrl} controls playsInline />
) : (
<img className="profile-page__detail-media" src={mediaUrl} alt="" />
);
};
const renderDetailModal = () => {
if (!detailSelection) return null;
const modalTarget = typeof document === "undefined" ? null : document.querySelector(".web-shell") || document.body;
if (!modalTarget) return null;
const isWork = detailSelection.kind === "work";
const title = isWork ? detailSelection.item.title : detailSelection.item.name;
const description = isWork ? detailSelection.item.prompt : detailSelection.item.description;
const mediaUrl = isWork ? detailSelection.item.outputUrl : detailSelection.item.imageUrl || detailSelection.item.url;
const mediaType = isWork
? detailSelection.item.type === "video" ? "video" : "image"
: detailSelection.item.type === "video" ? "video" : "asset";
return createPortal(
<div className="profile-page__detail-overlay" role="dialog" aria-modal="true" aria-labelledby="profile-detail-title">
<button type="button" className="profile-page__detail-backdrop" aria-label="关闭详情" onClick={closeDetailSelection} />
<section className="profile-page__detail-panel">
<header className="profile-page__detail-head">
<div>
<span className="profile-page__detail-eyebrow">{isWork ? "代表作详情" : "资产详情"}</span>
<h2 id="profile-detail-title">{title}</h2>
</div>
<button type="button" className="profile-page__detail-close" aria-label="关闭详情" onClick={closeDetailSelection}>
<CloseOutlined />
</button>
</header>
<div className="profile-page__detail-body">
<div className="profile-page__detail-preview">
{renderDetailMedia(mediaUrl, mediaType)}
</div>
<div className="profile-page__detail-info">
<p>{description || "暂无描述"}</p>
<dl>
<div>
<dt>{isWork ? "类型" : "资产类型"}</dt>
<dd>{isWork ? formatTaskType(detailSelection.item.type) : formatAssetType(detailSelection.item.type)}</dd>
</div>
<div>
<dt></dt>
<dd>{isWork ? formatTaskStatus(detailSelection.item.status) : formatAssetStatus(detailSelection.item.status)}</dd>
</div>
<div>
<dt>{isWork ? "创建时间" : "更新时间"}</dt>
<dd>{formatProfileDate(isWork ? detailSelection.item.createdAt : detailSelection.item.updatedAt)}</dd>
</div>
{!isWork ? (
<div>
<dt></dt>
<dd>{detailSelection.item.tags?.length ? detailSelection.item.tags.join(" / ") : "服务器素材"}</dd>
</div>
) : null}
</dl>
{detailNotice ? <span className="profile-page__detail-notice">{detailNotice}</span> : null}
</div>
</div>
<footer className="profile-page__detail-actions">
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--primary"
onClick={() => void handleDownloadSelectedDetail()}
disabled={isDownloadingDetail}
>
<DownloadOutlined />
{isDownloadingDetail ? "下载中..." : "下载"}
</button>
<button type="button" className="profile-page__detail-action profile-page__detail-action--secondary" onClick={onOpenWorkbench}>
<EditOutlined />
{isWork ? "继续编辑" : "使用素材"}
</button>
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--danger"
onClick={() => void handleDeleteSelectedDetail()}
disabled={isDeletingDetail}
>
<DeleteOutlined />
{isDeletingDetail ? "删除中..." : isWork ? "移除代表作" : "删除资产"}
</button>
</footer>
</section>
</div>,
modalTarget,
);
};
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 profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看代表作 ${task.title}`}
onClick={() => openDetailSelection({ kind: "work", item: task })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "work", item: task }))}
>
{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 profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`打开项目 ${project.name}`}
onClick={() => (onOpenProject ? onOpenProject(project) : onOpenWorkbench())}
onKeyDown={(event) =>
handleInteractiveCardKeyDown(event, () => (onOpenProject ? onOpenProject(project) : onOpenWorkbench()))
}
>
{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={(event) => {
event.stopPropagation();
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 profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看资产 ${asset.name}`}
onClick={() => openDetailSelection({ kind: "asset", item: asset })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "asset", item: asset }))}
>
{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()} aria-label="更换背景">
<CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</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")}
>
<span></span>
<strong>{(totalBalance / 100).toFixed(2)}</strong>
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
<span></span>
<strong>{tasks.length}</strong>
</button>
</div>
<div className="profile-page__account-summary">
{accountPanel === "credits" ? (
<>
<span className="profile-page__account-summary-main">
<small></small>
<strong>{displayName}</strong>
<em>{packageLabel}</em>
</span>
<span className="profile-page__account-summary-metric">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__account-summary-main">
<small></small>
<strong>{tasks.length} </strong>
<em>{completedTasks.length} </em>
</span>
<span className="profile-page__account-summary-metric">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
</div>
<div className="profile-page__actions">
<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>
</div>
</aside>
<main className="profile-page__main">
<div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
<span></span>
</button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
<span></span>
</button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
<span></span>
</button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
<span></span>
</button>
</div>
<div className="profile-page__section">
<div className="profile-page__section-head">
<span className="profile-page__section-label">{activePanelTitle}</span>
<span className="profile-page__section-desc">{activePanelDescription}</span>
<span className="profile-page__section-meta">{activePanelCount} </span>
</div>
{renderActivePanel()}
</div>
</main>
</div>
{renderDetailModal()}
</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;