1579 lines
65 KiB
TypeScript
1579 lines
65 KiB
TypeScript
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, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type FormEvent, type KeyboardEvent } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import "../../styles/pages/profile.css";
|
||
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 [isBannerDragging, setIsBannerDragging] = useState(false);
|
||
const [isAvatarDragging, setIsAvatarDragging] = useState(false);
|
||
|
||
const handleBannerDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsBannerDragging(true); };
|
||
const handleBannerDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsBannerDragging(false); };
|
||
const handleBannerDrop = (e: DragEvent) => {
|
||
e.preventDefault();
|
||
setIsBannerDragging(false);
|
||
if (e.dataTransfer.files.length) {
|
||
handleBannerUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||
}
|
||
};
|
||
|
||
const handleAvatarDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsAvatarDragging(true); };
|
||
const handleAvatarDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsAvatarDragging(false); };
|
||
const handleAvatarDrop = (e: DragEvent) => {
|
||
e.preventDefault();
|
||
setIsAvatarDragging(false);
|
||
if (e.dataTransfer.files.length) {
|
||
handleAvatarUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||
}
|
||
};
|
||
|
||
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 = useMemo(
|
||
() => tasks.filter((task) => task.status === "completed"),
|
||
[tasks],
|
||
);
|
||
const visibleWorks = useMemo(
|
||
() => (completedTasks.length ? completedTasks : tasks.slice(0, 6)),
|
||
[completedTasks, tasks],
|
||
);
|
||
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 url =
|
||
detailSelection.kind === "work"
|
||
? detailSelection.item.outputUrl
|
||
: detailSelection.item.imageUrl || detailSelection.item.url || "";
|
||
if (!url) {
|
||
setDetailNotice("暂无可下载的媒体文件");
|
||
return;
|
||
}
|
||
|
||
const isVideo =
|
||
detailSelection.kind === "work"
|
||
? detailSelection.item.type === "video"
|
||
: detailSelection.item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
|
||
const taskId = detailSelection.kind === "work" ? detailSelection.item.id : detailSelection.item.sourceTaskId || undefined;
|
||
const name = detailSelection.kind === "work" ? detailSelection.item.title : detailSelection.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" : ""}${isBannerDragging ? " is-dragging" : ""}`}
|
||
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
|
||
onDragOver={handleBannerDragOver}
|
||
onDragLeave={handleBannerDragLeave}
|
||
onDrop={handleBannerDrop}
|
||
>
|
||
<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${isAvatarDragging ? " is-dragging" : ""}`}>
|
||
{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()}
|
||
onDragOver={handleAvatarDragOver}
|
||
onDragLeave={handleAvatarDragLeave}
|
||
onDrop={handleAvatarDrop}
|
||
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;
|