Files
omniai-web/src/features/profile/ProfilePage.tsx
T
ludan 73a6043310 feat: 剧本评分SaaS化精修、画布视觉升级、电商克隆预览响应式修复
【剧本评分页面 SaaS 商业化精修】
- 上传区增加玻璃拟态渐变边框,hover 时高亮为品牌绿
- 已上传文件显示文件名+文件大小,重新上传按钮优化
- 上传按钮文案从"+ 上传剧本"改为"选择剧本",增加图标
- 评测按钮增加 LoadingOutlined/ThunderboltOutlined 图标动画
- 评测等待态增加分步加载提示(结构识别/冲突评估/商业潜力)
- 六维评分柱状图增加 hover/focus 交互:悬停维度高亮,其余维度 dim,底部显示当前维度详细说明
- 评分卡片、报告面板、历史记录项增加渐变背景与阴影层次
- 新增 script-tokens-v5 设计 Token 变量体系
- 响应式断点适配 1180px/900px/680px,移动端左右面板上下堆叠

【画布页面视觉升级】
- 画布背景增加网点纹理 + 径向渐变,增强空间感
- 项目栏/缩放控件增加玻璃拟态毛玻璃效果
- 节点卡片增加渐变背景、内阴影、边框高亮
- 选中节点增加品牌绿外圈光环 + 投影
- 连线 connector 增加 hover 品牌绿高亮
- 节点连线/选区框/缩放手柄统一品牌绿主题色
- 所有编辑器/菜单面板统一玻璃拟态风格
- 移动端 560px 项目栏改为 4 列网格布局,按钮显示中文标签(编辑/最近/导出/提交)

【电商克隆预览响应式修复】
- 短视口(高度≤760px)下预览面板内 header/空状态/底部输入改为静态流式布局
- 平板(≤860px)下预览面板取消绝对定位,改为 grid 流式布局
- 手机(≤620px)缩小间距与最小高度

【其他】
- 个人中心登录注册表单移除 form-kicker 标签
- 品牌色(auth-page__brand)调整为强调绿色
2026-06-03 18:21:10 +08:00

1100 lines
45 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,
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";
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 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);
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]);
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;
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);
};
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 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>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</article>
))}
</div>
) : (
renderEmptyState("向全世界展示你最得意的创作。", "开始创作", onOpenWorkbench)
);
}
if (activePanel === "projects") {
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
<span>{formatProfileDate(project.updatedAt)}</span>
{onDeleteProject ? (
<button
type="button"
className="profile-page__delete-project"
aria-label={`删除项目 ${project.name}`}
onClick={() => onDeleteProject(project)}
>
<DeleteOutlined />
</button>
) : null}
</div>
<p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span>
</div>
</article>
))}
</div>
) : (
renderEmptyState("还没有同步到服务器的项目。", "进入工作台", onOpenWorkbench)
);
}
if (activePanel === "assets") {
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{asset.name}</strong>
<span>{formatAssetStatus(asset.status)}</span>
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{asset.type}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</article>
))}
</div>
) : (
renderEmptyState(assetNotice || "服务器资产库暂无内容。", "去工作台生成", onOpenWorkbench)
);
}
return communityCases.length ? (
<div className="profile-page__review-list">
{communityCases.map((item) => (
<div key={item.id} className="profile-page__review-item">
{item.coverUrl ? <img src={item.coverUrl} alt="" /> : <span className="profile-page__review-thumb" />}
<div>
<strong>{item.title}</strong>
<span>
{item.status === "approved" ? "已通过" : item.status === "rejected" ? "未通过" : "审核中"}
</span>
<small>{item.copyCount} </small>
</div>
</div>
))}
</div>
) : (
renderEmptyState(communityNotice || "还没有发布到社区的内容。", "去社区发布", onOpenCommunity)
);
};
if (isLoggedIn) {
return (
<section className="profile-page profile-page--dashboard page-motion">
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
<header
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<CameraOutlined />
</button>
<div className="profile-page__banner-overlay" />
</header>
<div className="profile-page__body">
<aside className="profile-page__sidebar">
<div className="profile-page__sidebar-head">
<div className="profile-page__avatar-ring">
{avatarUrl ? (
<img className="profile-page__avatar" src={avatarUrl} alt="" />
) : (
<span className="profile-page__avatar">{avatarLabel}</span>
)}
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像">
<CameraOutlined />
</button>
<span className="profile-page__avatar-badge">
<CheckCircleFilled />
</span>
</div>
<strong className="profile-page__username">{displayName}</strong>
{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>
<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>
<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>
</>
) : (
<>
<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>
</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}
{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"></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}
</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}
{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}
{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>
</form>
</div>
</aside>
</section>
);
}
export default ProfilePage;