Feat/commercial saas polish #8
@@ -235,6 +235,9 @@ function ProfilePage({
|
|||||||
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
||||||
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
||||||
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
|
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(() => {
|
useEffect(() => {
|
||||||
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
|
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
|
||||||
@@ -812,13 +815,31 @@ function ProfilePage({
|
|||||||
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
|
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
<div className="auth-page__video-overlay">
|
<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>
|
<h1 className="auth-page__brand">OmniAI</h1>
|
||||||
|
</div>
|
||||||
<p className="auth-page__tagline">一句话,从创意到成片</p>
|
<p className="auth-page__tagline">一句话,从创意到成片</p>
|
||||||
<div className="auth-page__features">
|
<div className="auth-page__features">
|
||||||
<span>AI 视频生成</span>
|
<span>AI 视频生成</span>
|
||||||
<span>AI 图片创作</span>
|
<span>AI 图片创作</span>
|
||||||
<span>AI 电商素材</span>
|
<span>AI 电商素材</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -829,6 +850,7 @@ function ProfilePage({
|
|||||||
<span className="auth-page__logo">
|
<span className="auth-page__logo">
|
||||||
<img src={AUTH_LOGO_URL} alt="OmniAI" />
|
<img src={AUTH_LOGO_URL} alt="OmniAI" />
|
||||||
</span>
|
</span>
|
||||||
|
<span className="auth-page__form-kicker">{mode === "login" ? "账户登录" : "新用户注册"}</span>
|
||||||
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
|
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
|
||||||
<p className="auth-page__subtitle">
|
<p className="auth-page__subtitle">
|
||||||
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
|
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
|
||||||
@@ -868,7 +890,8 @@ function ProfilePage({
|
|||||||
<MailOutlined /> 邮箱
|
<MailOutlined /> 邮箱
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
|
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
|
||||||
<MobileOutlined /> 手机验证码
|
<MobileOutlined />
|
||||||
|
<span className="auth-page__tab-label-short">手机</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -924,6 +947,11 @@ function ProfilePage({
|
|||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
|
{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>
|
</label>
|
||||||
{mode === "login" ? (
|
{mode === "login" ? (
|
||||||
<div className="auth-page__forgot">
|
<div className="auth-page__forgot">
|
||||||
@@ -961,6 +989,11 @@ function ProfilePage({
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email ? <span className="auth-page__field-error">{fieldErrors.email}</span> : null}
|
{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>
|
||||||
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
|
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
|
||||||
<span>
|
<span>
|
||||||
@@ -975,6 +1008,11 @@ function ProfilePage({
|
|||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
|
{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>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -990,6 +1028,11 @@ function ProfilePage({
|
|||||||
<input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" />
|
<input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" />
|
||||||
</div>
|
</div>
|
||||||
{fieldErrors.phone ? <span className="auth-page__field-error">{fieldErrors.phone}</span> : null}
|
{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>
|
||||||
<label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
|
<label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
|
||||||
<span>
|
<span>
|
||||||
@@ -1015,6 +1058,11 @@ function ProfilePage({
|
|||||||
</span>
|
</span>
|
||||||
<input type="password" value={password} onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }} onBlur={() => handleFieldBlur("password", password)} placeholder="至少 6 位" autoComplete="new-password" />
|
<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}
|
{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>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ function WorkbenchPage({
|
|||||||
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
|
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
|
||||||
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||||
const lastScrollTopRef = useRef(0);
|
const lastScrollTopRef = useRef(0);
|
||||||
|
const scrollActionsHideTimerRef = useRef<number | null>(null);
|
||||||
const shouldFollowNewMessagesRef = useRef(true);
|
const shouldFollowNewMessagesRef = useRef(true);
|
||||||
const pendingScrollToLatestRef = useRef(true);
|
const pendingScrollToLatestRef = useRef(true);
|
||||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||||
@@ -273,6 +274,8 @@ function WorkbenchPage({
|
|||||||
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
||||||
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
|
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
|
||||||
const [composerHidden, setComposerHidden] = useState(false);
|
const [composerHidden, setComposerHidden] = useState(false);
|
||||||
|
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
|
||||||
|
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
|
||||||
const [workspaceStarted, setWorkspaceStarted] = useState(false);
|
const [workspaceStarted, setWorkspaceStarted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -441,6 +444,27 @@ function WorkbenchPage({
|
|||||||
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
|
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const revealScrollActionsTemporarily = useCallback((direction: "top" | "bottom") => {
|
||||||
|
setScrollActionDirection(direction);
|
||||||
|
setScrollActionsVisible(true);
|
||||||
|
if (scrollActionsHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrollActionsHideTimerRef.current);
|
||||||
|
}
|
||||||
|
scrollActionsHideTimerRef.current = window.setTimeout(() => {
|
||||||
|
setScrollActionsVisible(false);
|
||||||
|
setScrollActionDirection(null);
|
||||||
|
scrollActionsHideTimerRef.current = null;
|
||||||
|
}, 950);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scrollActionsHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(scrollActionsHideTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
|
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
const scroll = () => {
|
const scroll = () => {
|
||||||
const surface = messagesSurfaceRef.current;
|
const surface = messagesSurfaceRef.current;
|
||||||
@@ -451,6 +475,7 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
setComposerHidden(false);
|
setComposerHidden(false);
|
||||||
shouldFollowNewMessagesRef.current = true;
|
shouldFollowNewMessagesRef.current = true;
|
||||||
|
revealScrollActionsTemporarily("bottom");
|
||||||
surface.scrollTo({ top: surface.scrollHeight, behavior });
|
surface.scrollTo({ top: surface.scrollHeight, behavior });
|
||||||
lastScrollTopRef.current = surface.scrollTop;
|
lastScrollTopRef.current = surface.scrollTop;
|
||||||
};
|
};
|
||||||
@@ -459,7 +484,7 @@ function WorkbenchPage({
|
|||||||
scroll();
|
scroll();
|
||||||
window.setTimeout(scroll, 80);
|
window.setTimeout(scroll, 80);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [revealScrollActionsTemporarily]);
|
||||||
|
|
||||||
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
|
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -1373,6 +1398,9 @@ function WorkbenchPage({
|
|||||||
const delta = top - lastScrollTopRef.current;
|
const delta = top - lastScrollTopRef.current;
|
||||||
const atTop = top <= edgeThreshold;
|
const atTop = top <= edgeThreshold;
|
||||||
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
|
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
|
||||||
|
if (surface.scrollHeight > surface.clientHeight + edgeThreshold && Math.abs(delta) > 1) {
|
||||||
|
revealScrollActionsTemporarily(delta > 0 ? "bottom" : "top");
|
||||||
|
}
|
||||||
shouldFollowNewMessagesRef.current = atBottom;
|
shouldFollowNewMessagesRef.current = atBottom;
|
||||||
if (atTop || atBottom) {
|
if (atTop || atBottom) {
|
||||||
setComposerHidden(false);
|
setComposerHidden(false);
|
||||||
@@ -1384,7 +1412,7 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
surface.addEventListener("scroll", handleScroll, { passive: true });
|
surface.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => surface.removeEventListener("scroll", handleScroll);
|
return () => surface.removeEventListener("scroll", handleScroll);
|
||||||
}, [hasActivatedWorkspace]);
|
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
|
||||||
|
|
||||||
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
|
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
|
||||||
const surface = messagesSurfaceRef.current;
|
const surface = messagesSurfaceRef.current;
|
||||||
@@ -1392,8 +1420,9 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
const top = direction === "top" ? 0 : surface.scrollHeight;
|
const top = direction === "top" ? 0 : surface.scrollHeight;
|
||||||
setComposerHidden(false);
|
setComposerHidden(false);
|
||||||
|
revealScrollActionsTemporarily(direction);
|
||||||
surface.scrollTo({ top, behavior: "smooth" });
|
surface.scrollTo({ top, behavior: "smooth" });
|
||||||
}, []);
|
}, [revealScrollActionsTemporarily]);
|
||||||
|
|
||||||
const closeToolbarMenus = () => setToolbarMenuId(null);
|
const closeToolbarMenus = () => setToolbarMenuId(null);
|
||||||
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
|
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
|
||||||
@@ -3022,10 +3051,13 @@ function WorkbenchPage({
|
|||||||
{renderComposerToolbar(false, isGenerating)}
|
{renderComposerToolbar(false, isGenerating)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
|
<div
|
||||||
|
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
|
||||||
|
aria-label="聊天滚动"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="wb-chat-scroll-actions__button"
|
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
|
||||||
title="返回聊天顶部"
|
title="返回聊天顶部"
|
||||||
aria-label="返回聊天顶部"
|
aria-label="返回聊天顶部"
|
||||||
onClick={() => scrollMessagesSurface("top")}
|
onClick={() => scrollMessagesSurface("top")}
|
||||||
@@ -3034,7 +3066,7 @@ function WorkbenchPage({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="wb-chat-scroll-actions__button"
|
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
|
||||||
title="到达聊天底部"
|
title="到达聊天底部"
|
||||||
aria-label="到达聊天底部"
|
aria-label="到达聊天底部"
|
||||||
onClick={() => scrollMessagesSurface("bottom")}
|
onClick={() => scrollMessagesSurface("bottom")}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user