style: update desktop onboarding

This commit is contained in:
canisminor1990
2025-12-30 17:47:56 +08:00
parent cdd7a9239d
commit 0bb6b44fcd
35 changed files with 1134 additions and 3920 deletions
+3 -3
View File
@@ -23,7 +23,7 @@
"screen2.features.6.subtitle": "多模型服务商",
"screen2.features.6.title": "一个平台,连接所有模型",
"screen2.title": "你需要的一切",
"screen3.actions.grantAccess": "授予权限",
"screen3.actions.grantAccess": "授",
"screen3.actions.granted": "已授权",
"screen3.actions.openSettings": "打开设置",
"screen3.badge": "权限",
@@ -55,8 +55,8 @@
"screen5.actions.connectToServer": "连接服务器",
"screen5.actions.connecting": "正在连接…",
"screen5.actions.signInCloud": "登录 LobeHub Cloud",
"screen5.actions.signingIn": "正在登录…",
"screen5.actions.signOut": "返回并退出登录",
"screen5.actions.signingIn": "正在登录…",
"screen5.actions.signingOut": "正在退出…",
"screen5.actions.tryAgain": "重试",
"screen5.badge": "登录",
@@ -69,4 +69,4 @@
"screen5.navigation.next": "开始使用 LobeHub",
"screen5.selfhost.endpointPlaceholder": "Endpoint URL(示例:https://your-server.com",
"screen5.title": "连接你的账号"
}
}
@@ -3,7 +3,7 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OnboardingContainerWithTheme } from '@/features/DesktopOnboarding/OnboardingContainer';
import DesktopOnboardingComponent from '@/features/DesktopOnboarding';
import {
getDesktopOnboardingCompleted,
setDesktopOnboardingCompleted,
@@ -41,7 +41,7 @@ const DesktopOnboarding = () => {
}, []);
return (
<OnboardingContainerWithTheme
<DesktopOnboardingComponent
onComplete={() => {
setDesktopOnboardingCompleted();
// Use hard reload instead of SPA navigation to ensure the app boots with the new desktop state.
@@ -1,258 +0,0 @@
import { Button } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as motion from 'motion/react-m';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getThemeToken } from './styles/theme';
const themeToken = getThemeToken();
// 组件特有样式 - 仅用于 Navigation 组件
const styles = createStaticStyles(({ css, cssVar }) => ({
// 布局容器
container: css`
position: fixed;
inset-block-end: 0;
inset-inline: 0 0;
`,
content: css`
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
margin-block: 0;
margin-inline: auto;
padding-block: 24px;
padding-inline: 24px;
background: rgba(0, 0, 0, 85%);
backdrop-filter: blur(10px);
`,
// 高亮按钮样式 - 仅在启用状态下生效
highlightButton: css`
&:not(:disabled) {
border-color: ${themeToken.colorHighlight} !important;
font-weight: 500;
color: #000 !important;
background: ${themeToken.colorHighlight} !important;
& > * {
color: #000 !important;
}
&:hover {
border-color: #ffe227 !important;
background: #ffe227 !important;
}
}
`,
// 确保按钮在指示器上方
navigationButton: css`
position: relative;
z-index: 2;
&:not(:disabled) > * {
color: #000 !important;
}
`,
// 导航按钮区域 - 已整合到 content 中,不再需要
navigationContainer: css`
display: contents;
`,
placeholder: css`
width: 40px;
`,
// 进度指示器 - 绝对定位居中
progressContainer: css`
position: absolute;
z-index: 1;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 8px;
align-items: center;
`,
progressDot: css`
cursor: pointer;
height: 6px;
border-radius: 9999px;
transition: all 0.3s ease;
`,
progressDotActive: css`
width: 24px;
background-color: ${themeToken.colorHighlight};
`,
progressDotCompleted: css`
width: 6px;
background-color: ${cssVar.colorTextSecondary};
`,
progressDotPending: css`
width: 6px;
background-color: ${cssVar.colorTextQuaternary};
`,
squareButton: css`
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
`,
}));
interface NavigationProps {
// 是否高亮下一步按钮
// 动画配置
animate?: boolean;
// 动画持续时间(秒),默认 1
animationDelay?: number;
// 是否启用动画,默认 false
animationDuration?: number;
// 直接跳转到指定步骤
canGoNext: boolean;
canGoPrev: boolean;
currentStep: number;
nextButtonDisabled?: boolean;
// 是否禁用下一步按钮
nextButtonHighlight?: boolean;
nextButtonText?: string;
onGoToStep?: (step: number) => void;
onNext: () => void;
onPrev: () => void;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
totalSteps: number; // 动画延迟时间(秒),默认 0
}
export const Navigation: React.FC<NavigationProps> = ({
currentStep,
totalSteps,
onNext,
onPrev,
onGoToStep,
canGoNext,
canGoPrev,
prevButtonText = '',
nextButtonText,
showPrevButton = true,
showNextButton = true,
nextButtonDisabled = false,
nextButtonHighlight = false,
animate = false,
animationDuration = 1,
animationDelay = 0,
}) => {
const { t } = useTranslation('desktop-onboarding');
const resolvedNextButtonText = nextButtonText ?? t('navigation.next');
// 导航内容组件 - 单行布局
const navigationContent = (
<>
{/* 左侧返回按钮 */}
{showPrevButton ? (
prevButtonText ? (
// 有文字时显示完整按钮
<Button
className={styles.navigationButton}
disabled={!canGoPrev}
icon={<ChevronLeft size={16} />}
onClick={onPrev}
type={canGoPrev ? 'default' : 'dashed'}
>
{prevButtonText}
</Button>
) : (
// 无文字时只显示正方形图标按钮
<Button
className={cx(styles.squareButton, styles.navigationButton)}
disabled={!canGoPrev}
icon={<ChevronLeft size={16} />}
onClick={onPrev}
type={canGoPrev ? 'default' : 'dashed'}
/>
)
) : (
<div className={styles.placeholder} />
)}
{/* 中间进度指示器 */}
<div className={styles.progressContainer}>
{Array.from({ length: totalSteps }).map((_, index) => (
<motion.div
className={cx(
styles.progressDot,
index === currentStep && styles.progressDotActive,
index < currentStep && styles.progressDotCompleted,
index > currentStep && styles.progressDotPending,
)}
key={index}
onClick={() => onGoToStep && onGoToStep(index)}
/>
))}
</div>
{/* 右侧下一步按钮 */}
{showNextButton ? (
<Button
className={cx(styles.navigationButton, nextButtonHighlight && styles.highlightButton)}
disabled={nextButtonDisabled}
onClick={onNext}
type="primary"
>
{resolvedNextButtonText}
{canGoNext && !nextButtonDisabled && <ChevronRight size={16} />}
</Button>
) : (
<div className={styles.placeholder} />
)}
</>
);
// 根据是否需要动画选择容器
if (animate) {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={styles.container}
initial={{ opacity: 0, y: 20 }}
transition={{
delay: animationDelay,
duration: animationDuration,
ease: 'easeOut',
}}
>
<div className={styles.content}>{navigationContent}</div>
</motion.div>
);
}
// 无动画版本
return (
<div className={styles.container}>
<div className={styles.content}>{navigationContent}</div>
</div>
);
};
@@ -1,328 +1,52 @@
'use client';
import { ConfigProvider } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import { AnimatePresence, motion } from 'motion/react';
import React, { useCallback, useEffect, useState } from 'react';
import { Center, Flexbox, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cx, useThemeMode } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
import { electronSystemService } from '@/services/electron/system';
import { electronStylish } from '@/styles/electron';
import LangButton from '@/features/User/UserPanel/LangButton';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { Navigation } from './Navigation';
import LightRays from './effects/LightRays';
import { Screen1 } from './screens/Screen1';
import { Screen2 } from './screens/Screen2';
import { Screen3 } from './screens/Screen3';
import { Screen4 } from './screens/Screen4';
import { Screen5 } from './screens/Screen5';
import { customTheme } from './styles/theme';
import { styles } from './styles/container';
const styles = createStaticStyles(({ css }) => ({
backgroundLayer: css`
position: relative;
width: 100%;
height: 100%;
`,
container: css`
position: fixed;
z-index: 1;
inset-block: 0 0;
inset-inline: 0 0;
overflow: hidden;
color: #fff;
background-color: #000;
`,
content: css`
position: relative;
z-index: 10;
height: 100%;
background: transparent;
`,
navigationWrapper: css`
position: relative;
z-index: 100;
`,
screenContent: css`
position: relative;
z-index: 10;
height: 100%;
`,
screenWrapper: css`
width: 100%;
height: 100%;
`,
titleBar: css`
position: fixed;
z-index: 1000;
inset-block-start: 0;
inset-inline: 0 0;
width: 100%;
height: ${TITLE_BAR_HEIGHT}px;
`,
}));
interface OnboardingContainerProps {
onComplete: () => void;
}
const getIsMacFromNavigator = () => {
if (typeof navigator === 'undefined') return true;
// Electron (and most browsers) expose "MacIntel" for macOS.
return /Mac/i.test(navigator.platform);
};
const macScreens = [Screen1, Screen2, Screen3, Screen4, Screen5];
const nonMacScreens = [Screen1, Screen2, Screen4, Screen5];
// 统一的屏幕配置接口
interface ScreenConfig {
// 背景配置
background?: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
};
// 导航栏配置
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonDisabled?: boolean;
nextButtonHighlight?: boolean;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}
export const OnboardingContainer: React.FC<OnboardingContainerProps> = ({ onComplete }) => {
const [isMac, setIsMac] = useState(getIsMacFromNavigator);
const screens = isMac ? macScreens : nonMacScreens;
// 从 URL hash 获取初始屏幕索引
const getInitialStep = useCallback(
(totalSteps: number) => {
const hash = window.location.hash;
const match = hash.match(/^#(\d+)$/);
if (match) {
const step = parseInt(match[1], 10) - 1; // URL 使用 1-based,内部使用 0-based
if (step >= 0 && step < totalSteps) return step;
}
return 0; // 默认第一屏
},
[],
);
const [currentStep, setCurrentStep] = useState(() => getInitialStep(screens.length));
const [screenConfig, setScreenConfig] = useState<ScreenConfig>({
background: undefined,
navigation: {},
});
const [backgroundKey, setBackgroundKey] = useState(0);
const [previousStep, setPreviousStep] = useState(currentStep);
const totalSteps = screens.length;
// 检测平台:非 macOS 直接跳过 Screen3(权限页)
useEffect(() => {
let mounted = true;
const detectPlatform = async () => {
try {
const state = await electronSystemService.getAppState();
if (!mounted) return;
setIsMac(state.platform === 'darwin');
} catch {
// Fallback: keep navigator-based decision
}
};
void detectPlatform();
return () => {
mounted = false;
};
}, []);
// 当总步数改变时(例如非 macOS 过滤掉 Screen3),避免当前 step 越界
useEffect(() => {
setCurrentStep((step) => Math.min(step, Math.max(0, totalSteps - 1)));
}, [totalSteps]);
// 监听 hash 变化
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash;
const match = hash.match(/^#(\d+)$/);
if (match) {
const step = parseInt(match[1], 10) - 1;
if (step >= 0 && step < totalSteps && step !== currentStep) {
setCurrentStep(step);
}
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [currentStep, totalSteps]);
// 当 currentStep 改变时的处理
useEffect(() => {
// 更新 URL hash
const newHash = `#${currentStep + 1}`;
if (window.location.hash !== newHash) {
window.location.hash = newHash;
}
// 只有从其他屏切换到第一屏时才重置背景动画
if (currentStep === 0 && previousStep !== 0) {
setBackgroundKey((prev) => prev + 1);
}
setPreviousStep(currentStep);
}, [currentStep, previousStep]);
const handleNext = () => {
if (currentStep < totalSteps - 1) {
setCurrentStep(currentStep + 1);
} else {
onComplete();
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const CurrentScreen = screens[currentStep];
// 处理屏幕配置更改 - 统一处理导航和背景配置
const handleScreenConfigChange = useCallback(
(config: ScreenConfig) => {
setScreenConfig(config);
// 当背景配置改变时,更新 backgroundKey 以重新触发动画
if (currentStep === 0 && config.background) {
setBackgroundKey((prev) => prev + 1);
}
},
[currentStep],
);
// 渲染背景 - 只在第一屏时处理动画,其他屏幕直接显示
const renderBackground = () => {
const backgroundContent = (
<LightRays
className="custom-rays"
distortion={0}
followMouse={true}
lightSpread={1.5}
mouseInfluence={0.1}
noiseAmount={0.1}
rayLength={1.5}
raysColor="#732FAE"
raysColorSecondary="#3A31C1"
raysOrigin="top-center"
raysSpeed={1.3}
/>
);
// 统一使用 motion.div,避免组件类型切换导致重新挂载
const isFirstScreen = currentStep === 0;
const hasAnimation = screenConfig.background?.animate === true;
const backgroundConfig = screenConfig.background;
// 第一屏根据配置决定是否需要动画
const shouldAnimate = isFirstScreen && (hasAnimation || screenConfig.background === undefined);
const animationDelay = shouldAnimate ? (backgroundConfig?.animationDelay ?? 5) : 0;
const animationDuration = shouldAnimate ? (backgroundConfig?.animationDuration ?? 1) : 0;
return (
<motion.div
animate={{ opacity: 1 }}
className={styles.backgroundLayer}
initial={{ opacity: shouldAnimate ? 0 : 1 }}
key={`background-${backgroundKey}`} // 使用统一的动态key
transition={{
delay: animationDelay,
duration: animationDuration,
ease: 'easeOut',
}}
const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
const { isDarkMode } = useThemeMode();
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<Flexbox
className={cx(isDarkMode ? styles.innerContainerDark : styles.innerContainerLight)}
height={'100%'}
width={'100%'}
>
{backgroundContent}
</motion.div>
);
};
return (
<div className={styles.container}>
{/* Title Bar Drag Region */}
<div className={cx(styles.titleBar, electronStylish.draggable)} />
{/* LightRays Background Layer */}
{renderBackground()}
<div className={styles.content}>
{/* Screen content */}
<div className={styles.screenContent}>
<AnimatePresence>
<motion.div
animate={{ opacity: 1, x: 0 }}
className={styles.screenWrapper}
exit={{ opacity: 0, x: -100 }}
initial={{
opacity: currentStep === 0 ? 1 : 0,
x: currentStep === 0 ? 0 : 100,
}}
key={currentStep}
transition={{ duration: currentStep === 0 ? 0 : 0.3 }}
>
<CurrentScreen onScreenConfigChange={handleScreenConfigChange} />
</motion.div>
</AnimatePresence>
</div>
{/* Navigation */}
<div className={styles.navigationWrapper}>
<Navigation
animate={screenConfig.navigation.animate}
animationDelay={screenConfig.navigation.animationDelay}
animationDuration={screenConfig.navigation.animationDuration}
canGoNext={currentStep < totalSteps - 1}
canGoPrev={currentStep > 0}
currentStep={currentStep}
nextButtonDisabled={screenConfig.navigation.nextButtonDisabled}
nextButtonHighlight={screenConfig.navigation.nextButtonHighlight}
nextButtonText={screenConfig.navigation.nextButtonText}
onGoToStep={setCurrentStep}
onNext={handleNext}
onPrev={handlePrev}
prevButtonText={screenConfig.navigation.prevButtonText}
showNextButton={screenConfig.navigation.showNextButton}
showPrevButton={screenConfig.navigation.showPrevButton}
totalSteps={totalSteps}
/>
</div>
</div>
</div>
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
padding={16}
width={'100%'}
>
<div />
<Flexbox align={'center'} horizontal>
<LangButton placement={'bottomRight'} size={18} />
<Divider className={styles.divider} orientation={'vertical'} />
<ThemeButton placement={'bottomRight'} size={18} />
</Flexbox>
</Flexbox>
<Center height={'100%'} padding={16} width={'100%'}>
{children}
</Center>
<Center padding={24}>
<Text align={'center'} type={'secondary'}>
© 2025 LobeHub. All rights reserved.
</Text>
</Center>
</Flexbox>
</Flexbox>
);
};
export const OnboardingContainerWithTheme: React.FC<OnboardingContainerProps> = ({
onComplete,
}) => {
return (
<ConfigProvider theme={customTheme}>
<OnboardingContainer onComplete={onComplete} />
</ConfigProvider>
);
};
OnboardingContainer.displayName = 'OnboardingContainer';
export default OnboardingContainer;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

@@ -1,187 +0,0 @@
import { createStaticStyles, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { LogoBrand } from './LogoBrand';
const authResultStyles = createStaticStyles(({ css, cssVar }) => ({
connectionLine: css`
position: relative;
width: 60px;
height: 2px;
background: #666;
`,
container: css`
display: flex;
flex-direction: column;
align-items: center;
padding-block: 40px;
padding-inline: 20px;
text-align: center;
`,
description: css`
max-width: 400px;
margin: 0;
font-size: ${cssVar.fontSize};
line-height: 1.5;
color: rgba(255, 255, 255, 60%);
`,
deviceIcon: css`
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 72px;
border-radius: ${cssVar.borderRadiusLG};
background: #2d2d2d;
&::before {
content: '';
position: absolute;
inset-block-start: 8px;
inset-inline: 8px;
height: 6px;
border-radius: 2px;
background: #666;
}
&::after {
content: '';
position: absolute;
inset-block: 20px 8px;
inset-inline: 8px;
border-radius: 4px;
background: #444;
}
`,
errorIcon: css`
color: white;
background: ${cssVar.colorError};
`,
iconContainer: css`
display: flex;
gap: 24px;
align-items: center;
margin-block-end: 32px;
`,
logoContainer: css`
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 10%);
border-radius: 50%;
background: ${cssVar.colorBgElevated};
`,
statusIcon: css`
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
`,
successIcon: css`
color: white;
background: ${cssVar.colorSuccess};
`,
title: css`
margin-block: 0 16px;
margin-inline: 0;
font-family: ${cssVar.fontFamily};
font-size: 28px;
font-weight: 600;
color: ${cssVar.colorTextBase};
`,
}));
interface AuthResultProps {
animated?: boolean;
description?: string;
success: boolean;
title?: string;
}
export const AuthResult = ({ success, title, description, animated = true }: AuthResultProps) => {
const styles = authResultStyles;
const { t } = useTranslation('desktop-onboarding');
const defaultTitle = t(success ? 'authResult.success.title' : 'authResult.failed.title');
const defaultDescription = t(success ? 'authResult.success.desc' : 'authResult.failed.desc');
const content = (
<>
{/* 图标连接区域 */}
<div className={styles.iconContainer}>
{/* 设备图标 */}
<div className={styles.deviceIcon} />
{/* 连接线和状态图标 */}
<div className={styles.connectionLine}>
<div className={cx(styles.statusIcon, success ? styles.successIcon : styles.errorIcon)}>
{success ? <Check size={14} /> : <X size={14} />}
</div>
</div>
{/* Logo */}
<div className={styles.logoContainer}>
<LogoBrand animated={false} logoSize={88} spacing={0} textHeight={0} />
</div>
</div>
{/* 文字内容 */}
<h2 className={styles.title}>{title || defaultTitle}</h2>
<p className={styles.description}>{description || defaultDescription}</p>
</>
);
if (animated) {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={styles.container}
initial={{ opacity: 0, y: 30 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{content}
</motion.div>
);
}
return <div className={styles.container}>{content}</div>;
};
@@ -1,144 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { createStaticStyles, cx } from 'antd-style';
import { motion } from 'motion/react';
const logoImage = new URL('../assets/lobe256.png', import.meta.url).href;
const logoText = new URL('../assets/logo-text.svg', import.meta.url).href;
// LogoBrand 组件的样式
const logoBrandStyles = createStaticStyles(({ css }) => ({
// Logo 容器
logoContainer: css`
display: flex;
flex-direction: column;
align-items: center;
`,
// Logo 图片
logoImage: css`
object-fit: contain;
`,
// Logo 文字
logoText: css`
object-fit: contain;
`,
}));
interface LogoBrandProps {
// 是否启用动画
animated?: boolean;
// 动画配置
animation?: {
logo: {
delay: number;
duration: number;
scale?: { from: number; to: number };
};
logoText: {
delay: number;
duration: number;
yOffset?: number;
};
};
// 自定义样式类名
className?: string;
// Logo 图片尺寸
logoSize?: number;
// Logo 和文字之间的间距
spacing?: number;
// Logo 文字高度
textHeight?: number;
}
export const LogoBrand = ({
logoSize = 200,
textHeight = 56,
spacing = -20,
animated = false,
animation = {
logo: { delay: 0, duration: 0.7, scale: { from: 1, to: 1 } },
logoText: { delay: 0.3, duration: 0.7, yOffset: 0 },
},
className,
}: LogoBrandProps) => {
const styles = logoBrandStyles;
if (animated) {
return (
<motion.div className={cx(styles.logoContainer, className)}>
{/* Logo 图片 */}
<motion.div
animate={{
opacity: 1,
scale: animation.logo.scale?.to || 1,
}}
initial={{
opacity: 0,
scale: animation.logo.scale?.from || 1,
}}
style={{ marginBottom: spacing }}
transition={{
delay: animation.logo.delay,
duration: animation.logo.duration,
}}
>
<img
alt="Logo"
className={styles.logoImage}
src={logoImage}
style={{
height: logoSize,
width: logoSize,
}}
/>
</motion.div>
{/* Logo 文字 */}
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={{
opacity: 0,
y: animation.logoText.yOffset || 0,
}}
transition={{
delay: animation.logo.delay + animation.logoText.delay,
duration: animation.logoText.duration,
}}
>
<img
alt="Logo Text"
className={styles.logoText}
src={logoText}
style={{ height: textHeight }}
/>
</motion.div>
</motion.div>
);
}
// 静态版本(不带动画)
return (
<div className={cx(styles.logoContainer, className)}>
{/* Logo 图片 */}
<div style={{ marginBottom: spacing }}>
<img
alt="Logo"
className={styles.logoImage}
src={logoImage}
style={{
height: logoSize,
width: logoSize,
}}
/>
</div>
{/* Logo 文字 */}
<img
alt="Logo Text"
className={styles.logoText}
src={logoText}
style={{ height: textHeight }}
/>
</div>
);
};
@@ -1,72 +0,0 @@
import { createStaticStyles, cx } from 'antd-style';
import { motion } from 'motion/react';
import { typographyStyles } from '../styles';
// TitleSection 组件的样式
const titleSectionStyles = createStaticStyles(({ css }) => ({
// 标题区域容器
titleSection: css`
margin-block-end: 48px;
line-height: 1.4;
text-align: center;
`,
}));
interface TitleSectionProps {
// 是否启用动画
animated?: boolean;
// 动画配置
animation?: {
animate?: any;
initial?: any;
transition?: any;
};
// Badge 文字
badge: string;
// 自定义样式类名
className?: string;
// 描述文字
description: string;
// 主标题
title: string;
}
export const TitleSection = ({
badge,
title,
description,
animated = true,
animation = {
animate: { opacity: 1, y: 0 },
initial: { opacity: 0, y: -50 },
transition: { duration: 0.7 },
},
className,
}: TitleSectionProps) => {
const styles = titleSectionStyles;
const content = (
<>
<div className={typographyStyles.badge}>{badge}</div>
<h1 className={typographyStyles.subtitle}>{title}</h1>
<p className={typographyStyles.description}>{description}</p>
</>
);
if (animated) {
return (
<motion.div
animate={animation.animate}
className={cx(styles.titleSection, className)}
initial={animation.initial}
transition={animation.transition}
>
{content}
</motion.div>
);
}
// 静态版本(不带动画)
return <div className={cx(styles.titleSection, className)}>{content}</div>;
};
@@ -1,511 +0,0 @@
import { cx } from 'antd-style';
import { Mesh, Program, Renderer, Triangle } from 'ogl';
import React, { useEffect, useRef, useState } from 'react';
export type RaysOrigin =
| 'top-center'
| 'top-left'
| 'top-right'
| 'right'
| 'left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left';
interface LightRaysProps {
className?: string;
distortion?: number;
fadeDistance?: number;
followMouse?: boolean;
lightSpread?: number;
mouseInfluence?: number;
noiseAmount?: number;
pulsating?: boolean;
rayLength?: number;
raysColor?: string;
raysColorSecondary?: string;
raysOrigin?: RaysOrigin;
// 第二种颜色
raysSpeed?: number;
saturation?: number;
}
const DEFAULT_COLOR = '#ffffff';
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex);
return m
? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255]
: [1, 1, 1];
};
const getAnchorAndDir = (
origin: RaysOrigin,
w: number,
h: number,
): { anchor: [number, number]; dir: [number, number] } => {
const outside = 0.2;
switch (origin) {
case 'top-left': {
return { anchor: [0, -outside * h], dir: [0, 1] };
}
case 'top-right': {
return { anchor: [w, -outside * h], dir: [0, 1] };
}
case 'left': {
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
}
case 'right': {
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
}
case 'bottom-left': {
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
}
case 'bottom-center': {
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
}
case 'bottom-right': {
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
}
default: {
// "top-center"
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
}
}
};
const LightRays: React.FC<LightRaysProps> = ({
raysOrigin = 'top-center',
raysColor = DEFAULT_COLOR,
raysColorSecondary,
raysSpeed = 1,
lightSpread = 1,
rayLength = 2,
pulsating = false,
fadeDistance = 1,
saturation = 1,
followMouse = true,
mouseInfluence = 0.1,
noiseAmount = 0,
distortion = 0,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const uniformsRef = useRef<any>(null);
const rendererRef = useRef<Renderer | null>(null);
const mouseRef = useRef({ x: 0.5, y: 0.5 });
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
const animationIdRef = useRef<number | null>(null);
const meshRef = useRef<any>(null);
const cleanupFunctionRef = useRef<(() => void) | null>(null);
const [isVisible, setIsVisible] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!containerRef.current) return;
observerRef.current = new IntersectionObserver(
(entries) => {
const entry = entries[0];
setIsVisible(entry.isIntersecting);
},
{ threshold: 0.1 },
);
observerRef.current.observe(containerRef.current);
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!isVisible || !containerRef.current) return;
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current();
cleanupFunctionRef.current = null;
}
const initializeWebGL = async () => {
if (!containerRef.current) return;
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
if (!containerRef.current) return;
const renderer = new Renderer({
alpha: true,
dpr: Math.min(window.devicePixelRatio, 2),
});
rendererRef.current = renderer;
const gl = renderer.gl;
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
while (containerRef.current.firstChild) {
containerRef.current.firstChild.remove();
}
containerRef.current.append(gl.canvas);
const vert = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const frag = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform vec3 raysColorSecondary;
uniform float hasSecondaryColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
// 增强中心光束的强度,使光线更加聚焦
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
// 调整衰减曲线,使光线更加柔和
float fadeFalloff = pow(clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.0, 1.0), 0.8);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
// 增强动态变化的幅度
float baseStrength = clamp(
(0.5 + 0.25 * sin(distortedAngle * seedA + iTime * speed)) +
(0.35 + 0.3 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.2
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
// 创建两组光束:主色和次色
float ray1 = rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349, 1.5 * raysSpeed);
float ray2 = rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234, 1.1 * raysSpeed);
float ray3 = rayStrength(rayPos, finalRayDir, coord, 15.5423, 27.3342, 0.8 * raysSpeed);
float ray4 = rayStrength(rayPos, finalRayDir, coord, 41.2314, 11.2341, 1.3 * raysSpeed);
// 主色光束组合
float primaryIntensity = ray1 * 0.35 + ray2 * 0.3;
// 次色光束组合
float secondaryIntensity = ray3 * 0.25 + ray4 * 0.2;
if (hasSecondaryColor > 0.5) {
// 双色模式:使用渐变混合
vec3 primaryColor = raysColor * primaryIntensity;
vec3 secondaryColor = raysColorSecondary * secondaryIntensity;
// 基于距离的颜色混合,创建渐变效果
float distance = length(coord - rayPos);
float maxDist = iResolution.x * rayLength * 0.8;
float mixFactor = smoothstep(0.0, maxDist, distance);
// 混合两种颜色
fragColor.rgb = mix(primaryColor, secondaryColor, mixFactor);
// 在交叠区域增强亮度
float totalIntensity = primaryIntensity + secondaryIntensity;
float overlapBoost = smoothstep(0.8, 2.0, totalIntensity);
fragColor.rgb += fragColor.rgb * overlapBoost * 0.4;
// 添加色彩混合的高光效果
float colorBlend = primaryIntensity * secondaryIntensity;
vec3 blendedHighlight = mix(raysColor, raysColorSecondary, 0.5) * colorBlend * 0.6;
fragColor.rgb += blendedHighlight;
fragColor.a = totalIntensity;
} else {
// 单色模式:保持原有逻辑
float totalIntensity = primaryIntensity + secondaryIntensity;
fragColor = vec4(raysColor * totalIntensity, totalIntensity);
float overlapBoost = smoothstep(1.0, 2.5, totalIntensity);
fragColor.rgb += fragColor.rgb * overlapBoost * 0.3;
}
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
// 只在单色模式下应用额外的颜色调整
if (hasSecondaryColor < 0.5) {
float brightness = 1.0 - (coord.y / iResolution.y);
fragColor.x *= 0.2 + brightness * 0.9;
fragColor.y *= 0.4 + brightness * 0.7;
fragColor.z *= 0.6 + brightness * 0.6;
}
if (saturation != 1.0) {
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
}
// 软限制最大亮度,避免过曝
fragColor.rgb = fragColor.rgb / (1.0 + fragColor.rgb * 0.3);
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}`;
const uniforms = {
distortion: { value: distortion },
fadeDistance: { value: fadeDistance },
hasSecondaryColor: { value: raysColorSecondary ? 1 : 0 },
iResolution: { value: [1, 1] },
iTime: { value: 0 },
lightSpread: { value: lightSpread },
mouseInfluence: { value: mouseInfluence },
mousePos: { value: [0.5, 0.5] },
noiseAmount: { value: noiseAmount },
pulsating: { value: pulsating ? 1 : 0 },
rayDir: { value: [0, 1] },
rayLength: { value: rayLength },
rayPos: { value: [0, 0] },
raysColor: { value: hexToRgb(raysColor) },
raysColorSecondary: {
value: raysColorSecondary ? hexToRgb(raysColorSecondary) : [0, 0, 0],
},
raysSpeed: { value: raysSpeed },
saturation: { value: saturation },
};
uniformsRef.current = uniforms;
const geometry = new Triangle(gl);
const program = new Program(gl, {
fragment: frag,
uniforms,
vertex: vert,
});
const mesh = new Mesh(gl, { geometry, program });
meshRef.current = mesh;
const updatePlacement = () => {
if (!containerRef.current || !renderer) return;
renderer.dpr = Math.min(window.devicePixelRatio, 2);
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
renderer.setSize(wCSS, hCSS);
const dpr = renderer.dpr;
const w = wCSS * dpr;
const h = hCSS * dpr;
uniforms.iResolution.value = [w, h];
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h);
uniforms.rayPos.value = anchor;
uniforms.rayDir.value = dir;
};
const loop = (t: number) => {
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
return;
}
uniforms.iTime.value = t * 0.001;
if (followMouse && mouseInfluence > 0) {
const smoothing = 0.92;
smoothMouseRef.current.x =
smoothMouseRef.current.x * smoothing + mouseRef.current.x * (1 - smoothing);
smoothMouseRef.current.y =
smoothMouseRef.current.y * smoothing + mouseRef.current.y * (1 - smoothing);
uniforms.mousePos.value = [smoothMouseRef.current.x, smoothMouseRef.current.y];
}
try {
renderer.render({ scene: mesh });
animationIdRef.current = requestAnimationFrame(loop);
} catch (error) {
console.warn('WebGL rendering error:', error);
return;
}
};
window.addEventListener('resize', updatePlacement);
updatePlacement();
animationIdRef.current = requestAnimationFrame(loop);
cleanupFunctionRef.current = () => {
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
window.removeEventListener('resize', updatePlacement);
if (renderer) {
try {
const canvas = renderer.gl.canvas;
const loseContextExt = renderer.gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
if (canvas && canvas.parentNode) {
canvas.remove();
}
} catch (error) {
console.warn('Error during WebGL cleanup:', error);
}
}
rendererRef.current = null;
uniformsRef.current = null;
meshRef.current = null;
};
};
initializeWebGL();
return () => {
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current();
cleanupFunctionRef.current = null;
}
};
}, [
isVisible,
raysOrigin,
raysColor,
raysColorSecondary,
raysSpeed,
lightSpread,
rayLength,
pulsating,
fadeDistance,
saturation,
followMouse,
mouseInfluence,
noiseAmount,
distortion,
]);
useEffect(() => {
if (!uniformsRef.current || !containerRef.current || !rendererRef.current) return;
const u = uniformsRef.current;
const renderer = rendererRef.current;
u.raysColor.value = hexToRgb(raysColor);
u.raysColorSecondary.value = raysColorSecondary ? hexToRgb(raysColorSecondary) : [0, 0, 0];
u.hasSecondaryColor.value = raysColorSecondary ? 1 : 0;
u.raysSpeed.value = raysSpeed;
u.lightSpread.value = lightSpread;
u.rayLength.value = rayLength;
u.pulsating.value = pulsating ? 1 : 0;
u.fadeDistance.value = fadeDistance;
u.saturation.value = saturation;
u.mouseInfluence.value = mouseInfluence;
u.noiseAmount.value = noiseAmount;
u.distortion.value = distortion;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
const dpr = renderer.dpr;
const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr);
u.rayPos.value = anchor;
u.rayDir.value = dir;
}, [
raysColor,
raysColorSecondary,
raysSpeed,
lightSpread,
raysOrigin,
rayLength,
pulsating,
fadeDistance,
saturation,
mouseInfluence,
noiseAmount,
distortion,
]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current || !rendererRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseRef.current = { x, y };
};
if (followMouse) {
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}
}, [followMouse]);
return (
<div
className={cx('light-rays-container', className)}
ref={containerRef}
style={{
height: '100%',
pointerEvents: 'none',
position: 'relative',
width: '100%',
zIndex: 5,
}}
/>
);
};
export default LightRays;
@@ -0,0 +1,134 @@
'use client';
import { Block, Button, Checkbox, Empty, Flexbox, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { HeartHandshake, Undo2Icon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LobeMessage from '@/app/[variants]/onboarding/components/LobeMessage';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
type DataMode = 'share' | 'privacy';
interface DataModeStepProps {
onBack: () => void;
onNext: () => void;
}
const DataModeStep = memo<DataModeStepProps>(({ onBack, onNext }) => {
const { t } = useTranslation('desktop-onboarding');
const telemetryEnabled = useUserStore(userGeneralSettingsSelectors.telemetry);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const [selectedMode, setSelectedMode] = useState<DataMode>(
telemetryEnabled ? 'share' : 'privacy',
);
const setMode = useCallback(
(mode: DataMode) => {
setSelectedMode(mode);
const nextTelemetry = mode === 'share';
if (telemetryEnabled !== nextTelemetry) {
void updateGeneralConfig({ telemetry: nextTelemetry });
}
},
[telemetryEnabled, updateGeneralConfig],
);
const checkIcon = (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked
shape={'circle'}
size={20}
style={{ position: 'absolute', right: 12, top: 12 }}
/>
);
return (
<Flexbox gap={16}>
<Flexbox>
<LobeMessage sentences={[t('screen4.title'), t('screen4.title2'), t('screen4.title3')]} />
<Text as={'p'}>{t('screen4.description')}</Text>
</Flexbox>
<Flexbox gap={16} style={{ width: '100%' }}>
{/* 共享数据选项 */}
<Block
clickable
flex={1}
gap={16}
onClick={() => setMode('share')}
padding={16}
style={{ borderColor: selectedMode === 'share' ? cssVar.colorSuccess : undefined }}
variant={'outlined'}
>
{selectedMode === 'share' && checkIcon}
<Empty
description={t('screen4.share.description')}
descriptionProps={{
fontSize: 14,
}}
icon={HeartHandshake}
padding={0}
title={t('screen4.share.title')}
titleProps={{
fontSize: 18,
}}
type={'page'}
/>
<Flexbox as={'ul'} gap={4} style={{ listStyle: 'none', padding: 0 }}>
<li>
<Text> {t('screen4.share.items.1')}</Text>
</li>
<li>
<Text> {t('screen4.share.items.2')}</Text>
</li>
<li>
<Text> {t('screen4.share.items.3')}</Text>
</li>
</Flexbox>
</Block>
{/* 隐私模式选项 */}
<Block
clickable
flex={1}
gap={6}
onClick={() => setMode('privacy')}
padding={16}
style={{ borderColor: selectedMode === 'privacy' ? cssVar.colorSuccess : undefined }}
variant={'outlined'}
>
{selectedMode === 'privacy' && checkIcon}
<Text fontSize={18} strong>
{t('screen4.privacy.title')}
</Text>
<Text fontSize={14} type={'secondary'}>
{t('screen4.privacy.description')}
</Text>
</Block>
</Flexbox>
<Text color={cssVar.colorTextSecondary} fontSize={12} style={{ marginTop: 16 }}>
{t('screen4.footerNote')}
</Text>
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
</Flexbox>
</Flexbox>
);
});
DataModeStep.displayName = 'DataModeStep';
export default DataModeStep;
@@ -0,0 +1,351 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Alert, Button, Center, Flexbox, Icon, Input, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
import { Cloud, Server, Undo2Icon } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LobeMessage from '@/app/[variants]/onboarding/components/LobeMessage';
import { isDesktop } from '@/const/version';
import { useElectronStore } from '@/store/electron';
import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
// 登录方式类型
type LoginMethod = 'cloud' | 'selfhost';
// 登录状态类型
type LoginStatus = 'idle' | 'loading' | 'success' | 'error';
const loginMethodMetas = {
cloud: {
descriptionKey: 'screen5.methods.cloud.description',
icon: Cloud,
id: 'cloud' as LoginMethod,
nameKey: 'screen5.methods.cloud.name',
},
selfhost: {
descriptionKey: 'screen5.methods.selfhost.description',
icon: Server,
id: 'selfhost' as LoginMethod,
nameKey: 'screen5.methods.selfhost.name',
},
} as const satisfies Record<LoginMethod, unknown>;
interface LoginStepProps {
onBack: () => void;
onNext: () => void;
}
const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const { t } = useTranslation('desktop-onboarding');
const [endpoint, setEndpoint] = useState('');
const [cloudLoginStatus, setCloudLoginStatus] = useState<LoginStatus>('idle');
const [selfhostLoginStatus, setSelfhostLoginStatus] = useState<LoginStatus>('idle');
const [remoteError, setRemoteError] = useState<string | null>(null);
const [isSigningOut, setIsSigningOut] = useState(false);
const [showEndpoint, setShowEndpoint] = useState(false);
const [
dataSyncConfig,
isConnectingServer,
remoteServerSyncError,
useDataSyncConfig,
connectRemoteServer,
refreshServerConfig,
clearRemoteServerSyncError,
disconnectRemoteServer,
] = useElectronStore((s) => [
s.dataSyncConfig,
s.isConnectingServer,
s.remoteServerSyncError,
s.useDataSyncConfig,
s.connectRemoteServer,
s.refreshServerConfig,
s.clearRemoteServerSyncError,
s.disconnectRemoteServer,
]);
// Ensure remote server config is loaded early (desktop only hook)
useDataSyncConfig();
const isCloudAuthed = !!dataSyncConfig?.active && dataSyncConfig.storageMode === 'cloud';
const isSelfHostAuthed = !!dataSyncConfig?.active && dataSyncConfig.storageMode === 'selfHost';
const isSelfHostEndpointVerified =
isSelfHostAuthed &&
!!endpoint.trim() &&
endpoint.trim() === (dataSyncConfig?.remoteServerUrl ?? '');
// 判断是否可以开始使用(任一方式成功即可)
const canStart = () => {
return isCloudAuthed || cloudLoginStatus === 'success' || isSelfHostEndpointVerified;
};
// 处理云端登录
const handleCloudLogin = async () => {
if (!isDesktop) {
setRemoteError(t('screen5.errors.desktopOnlyOidc'));
setCloudLoginStatus('error');
return;
}
setRemoteError(null);
clearRemoteServerSyncError();
setCloudLoginStatus('loading');
setDesktopAutoOidcFirstOpenHandled();
await connectRemoteServer({
remoteServerUrl: dataSyncConfig?.remoteServerUrl,
storageMode: 'cloud',
});
};
// 处理自建服务器连接
const handleSelfhostConnect = async () => {
if (!isDesktop) {
setRemoteError(t('screen5.errors.desktopOnlyOidc'));
setSelfhostLoginStatus('error');
return;
}
const url = endpoint.trim();
if (!url) return;
setRemoteError(null);
clearRemoteServerSyncError();
setSelfhostLoginStatus('loading');
await connectRemoteServer({ remoteServerUrl: url, storageMode: 'selfHost' });
};
// 退出登录(断开远程同步授权)并回到登录选择
const handleSignOut = async () => {
if (isSigningOut) return;
setIsSigningOut(true);
setRemoteError(null);
clearRemoteServerSyncError();
try {
await disconnectRemoteServer();
await refreshServerConfig();
} finally {
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setEndpoint('');
setIsSigningOut(false);
}
};
// Sync local UI status with real remote config
useEffect(() => {
if (isCloudAuthed) setCloudLoginStatus('success');
if (isSelfHostEndpointVerified) setSelfhostLoginStatus('success');
}, [isCloudAuthed, isSelfHostEndpointVerified]);
// If user changes self-host endpoint after success, require re-authorization.
useEffect(() => {
if (selfhostLoginStatus !== 'success') return;
if (isSelfHostEndpointVerified) return;
setSelfhostLoginStatus('idle');
}, [isSelfHostEndpointVerified, selfhostLoginStatus]);
// Surface requestAuthorization errors reported via store
useEffect(() => {
const message = remoteServerSyncError?.message;
if (!message) return;
setRemoteError(message);
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
}, [remoteServerSyncError?.message, cloudLoginStatus, selfhostLoginStatus]);
// Watch broadcasts from main process (polling result)
useWatchBroadcast('authorizationSuccessful', async () => {
setRemoteError(null);
clearRemoteServerSyncError();
await refreshServerConfig();
});
useWatchBroadcast('authorizationFailed', ({ error }) => {
setRemoteError(error);
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
});
// 渲染 Cloud 登录内容
const renderCloudContent = () => {
if (cloudLoginStatus === 'success') {
return (
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Cloud}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
);
}
if (cloudLoginStatus === 'error') {
return (
<>
<Alert
description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')}
type={'secondary'}
/>
<Button
block
icon={Cloud}
onClick={() => setCloudLoginStatus('idle')}
size={'large'}
type={'primary'}
>
{t('screen5.actions.tryAgain')}
</Button>
</>
);
}
return (
<Button
block
disabled={cloudLoginStatus === 'loading' || isConnectingServer}
icon={Cloud}
loading={cloudLoginStatus === 'loading'}
onClick={handleCloudLogin}
size={'large'}
type={'primary'}
>
{cloudLoginStatus === 'loading'
? t('screen5.actions.signingIn')
: t('screen5.actions.signInCloud')}
</Button>
);
};
// 渲染 Self-host 登录内容
const renderSelfhostContent = () => {
if (selfhostLoginStatus === 'success') {
return (
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Server}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
);
}
if (selfhostLoginStatus === 'error') {
return (
<Flexbox gap={16}>
<Alert
description={remoteError || t('authResult.failed.desc')}
style={{ width: '100%' }}
title={t('authResult.failed.title')}
type={'secondary'}
/>
<Button icon={Server} onClick={() => setSelfhostLoginStatus('idle')} type={'primary'}>
{t('screen5.actions.tryAgain')}
</Button>
</Flexbox>
);
}
return (
<Flexbox gap={16} style={{ width: '100%' }}>
<Text color={cssVar.colorTextSecondary}>{t(loginMethodMetas.selfhost.descriptionKey)}</Text>
<Input
onChange={(e) => setEndpoint(e.target.value)}
onContextMenu={async (e) => {
if (!isDesktop) return;
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
await electronSystemService.showContextMenu('edit');
}}
placeholder={t('screen5.selfhost.endpointPlaceholder')}
prefix={<Icon icon={Server} style={{ marginRight: 4 }} />}
size={'large'}
style={{ width: '100%' }}
value={endpoint}
/>
<Button
disabled={!endpoint.trim() || selfhostLoginStatus === 'loading' || isConnectingServer}
loading={selfhostLoginStatus === 'loading'}
onClick={handleSelfhostConnect}
size={'large'}
style={{ width: '100%' }}
type={'primary'}
>
{selfhostLoginStatus === 'loading'
? t('screen5.actions.connecting')
: t('screen5.actions.connectToServer')}
</Button>
</Flexbox>
);
};
return (
<Flexbox gap={32}>
<Flexbox>
<LobeMessage sentences={[t('screen5.title'), t('screen5.title2'), t('screen5.title3')]} />
<Text as={'p'}>{t('screen5.description')}</Text>
</Flexbox>
<Flexbox align={'flex-start'} gap={16} style={{ width: '100%' }} width={'100%'}>
{renderCloudContent()}
{!showEndpoint ? (
<Center width={'100%'}>
<Button
onClick={() => setShowEndpoint(true)}
style={{
color: cssVar.colorTextSecondary,
}}
type={'text'}
>
{t(loginMethodMetas.selfhost.descriptionKey)}
</Button>
</Center>
) : (
<>
<Divider>
<Text fontSize={12} type={'secondary'}>
OR
</Text>
</Divider>
{/* Self-host 选项 */}
{renderSelfhostContent()}
</>
)}
</Flexbox>
{canStart() && (
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={onNext} type={'primary'}>
{t('screen5.navigation.next')}
</Button>
</Flexbox>
)}
</Flexbox>
);
});
LoginStep.displayName = 'LoginStep';
export default LoginStep;
@@ -0,0 +1,242 @@
'use client';
import { Block, Button, Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import {
Bell,
Check,
FolderOpen,
Mic,
MonitorCog,
SquareArrowOutUpRight,
Undo2Icon,
} from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LobeMessage from '@/app/[variants]/onboarding/components/LobeMessage';
import { ensureElectronIpc } from '@/utils/electron/ipc';
type PermissionMeta = {
descriptionKey: string;
icon: typeof Bell;
iconColor: string;
id: number;
titleKey: string;
};
type PermissionButtonKey = 'screen3.actions.grantAccess' | 'screen3.actions.openSettings';
type PermissionItem = PermissionMeta & {
buttonKey: PermissionButtonKey;
granted: boolean;
};
const permissionMetas: PermissionMeta[] = [
{
descriptionKey: 'screen3.permissions.1.description',
icon: Bell,
iconColor: '#FFCB47',
id: 1,
titleKey: 'screen3.permissions.1.title',
},
{
descriptionKey: 'screen3.permissions.2.description',
icon: FolderOpen,
iconColor: '#67AF3F',
id: 2,
titleKey: 'screen3.permissions.2.title',
},
{
descriptionKey: 'screen3.permissions.3.description',
icon: Mic,
iconColor: '#4A77FF',
id: 3,
titleKey: 'screen3.permissions.3.title',
},
{
descriptionKey: 'screen3.permissions.4.description',
icon: MonitorCog,
iconColor: '#7A45D3',
id: 4,
titleKey: 'screen3.permissions.4.title',
},
];
interface PermissionsStepProps {
onBack: () => void;
onNext: () => void;
}
const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
const { t } = useTranslation('desktop-onboarding');
const [permissions, setPermissions] = useState<PermissionItem[]>(() =>
permissionMetas.map((p) => ({
...p,
buttonKey: 'screen3.actions.grantAccess',
granted: false,
})),
);
const checkAllPermissions = useCallback(async () => {
const ipc = ensureElectronIpc();
if (!ipc) return;
const state = await ipc.system.getAppState();
const isMac = state.platform === 'darwin';
if (!isMac) {
// If not on macOS, assume all permissions are granted
setPermissions((prev) => prev.map((p) => ({ ...p, granted: true })));
return;
}
const notifStatus = await ipc.notification.getNotificationPermissionStatus();
const micStatus = await ipc.system.getMediaAccessStatus('microphone');
const screenStatus = await ipc.system.getMediaAccessStatus('screen');
const accessibilityStatus = await ipc.system.getAccessibilityStatus();
setPermissions((prev) =>
prev.map((p) => {
if (p.id === 1) return { ...p, granted: notifStatus === 'authorized' };
// Full Disk Access cannot be checked programmatically, so it remains manual
if (p.id === 2) return { ...p, buttonKey: 'screen3.actions.openSettings', granted: false };
if (p.id === 3)
return { ...p, granted: micStatus === 'granted' && screenStatus === 'granted' };
if (p.id === 4) return { ...p, granted: accessibilityStatus };
return p;
}),
);
}, []);
useEffect(() => {
checkAllPermissions();
}, [checkAllPermissions]);
// When this page regains focus (e.g. back from System Settings), re-check permission states and refresh UI.
useEffect(() => {
if (typeof window === 'undefined') return;
const handleFocus = () => {
checkAllPermissions();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkAllPermissions();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [checkAllPermissions]);
const handlePermissionRequest = async (permissionId: number) => {
const ipc = ensureElectronIpc();
if (!ipc) return;
switch (permissionId) {
case 1: {
await ipc.notification.requestNotificationPermission();
break;
}
case 2: {
await ipc.system.openFullDiskAccessSettings({ autoAdd: true });
break;
}
case 3: {
await ipc.system.requestMicrophoneAccess();
await ipc.system.requestScreenAccess();
break;
}
case 4: {
await ipc.system.requestAccessibilityAccess();
break;
}
default: {
break;
}
}
// Re-check permissions after a short delay to allow system dialogs
setTimeout(() => {
void checkAllPermissions();
}, 1000);
};
return (
<Flexbox gap={16}>
<Flexbox>
<LobeMessage sentences={[t('screen3.title'), t('screen3.title2'), t('screen3.title3')]} />
<Text as={'p'}>{t('screen3.description')}</Text>
</Flexbox>
<Block gap={12} padding={4} style={{ width: '100%' }} variant={'outlined'}>
{permissions.map((permission) => (
<Block
align={'center'}
clickable={!permission.granted || permission.id === 2}
gap={16}
horizontal
key={permission.id}
onClick={() => !permission.granted && handlePermissionRequest(permission.id)}
paddingBlock={8}
paddingInline={'12px 12px'}
style={{
background: permission.granted ? cssVar.colorFillSecondary : undefined,
borderColor: permission.granted ? cssVar.colorSuccess : undefined,
}}
variant={'borderless'}
>
<Block align={'center'} height={40} justify={'center'} variant={'outlined'} width={40}>
<Icon color={cssVar.colorTextDescription} icon={permission.icon} size={20} />
</Block>
<Flexbox gap={2} style={{ flex: 1 }}>
<Text weight={500}>{t(permission.titleKey as any)}</Text>
<Text color={cssVar.colorTextSecondary} fontSize={12}>
{t(permission.descriptionKey as any)}
</Text>
</Flexbox>
{permission.granted && permission.id !== 2 ? (
<Icon color={cssVar.colorSuccess} icon={Check} size={20} />
) : (
<Button
icon={SquareArrowOutUpRight}
iconPosition={'end'}
onClick={(e) => {
e.stopPropagation();
handlePermissionRequest(permission.id);
}}
size={'small'}
style={{
color: cssVar.colorTextSecondary,
}}
type={'text'}
>
{permission.granted && permission.id === 2
? t('screen3.actions.granted')
: t(permission.buttonKey)}
</Button>
)}
</Block>
))}
</Block>
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
icon={Undo2Icon}
onClick={onBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={onNext} type={'primary'}>
{t('next')}
</Button>
</Flexbox>
</Flexbox>
);
});
PermissionsStep.displayName = 'PermissionsStep';
export default PermissionsStep;
@@ -0,0 +1,129 @@
'use client';
import { Block, Button, Flexbox, Icon, type IconProps, Text } from '@lobehub/ui';
import { TypewriterEffect } from '@lobehub/ui/awesome';
import { LoadingDots } from '@lobehub/ui/chat';
import { Steps } from 'antd';
import { cssVar } from 'antd-style';
import { BrainIcon, HeartHandshakeIcon, PencilRulerIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ProductLogo } from '@/components/Branding';
import { useUserStore } from '@/store/user';
interface WelcomeStepProps {
onNext: () => void;
}
const WelcomeStep = memo<WelcomeStepProps>(({ onNext }) => {
const { t } = useTranslation('onboarding');
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const handleNext = () => {
// 默认启用 telemetry
updateGeneralConfig({ telemetry: true });
onNext();
};
const IconAvatar = useCallback(({ icon }: { icon: IconProps['icon'] }) => {
return (
<Block
align="center"
height={32}
justify="center"
padding={4}
shadow
variant="outlined"
width={32}
>
<Icon color={cssVar.colorTextDescription} icon={icon} size={16} />
</Block>
);
}, []);
return (
<Flexbox gap={16}>
<ProductLogo size={64} />
<Flexbox style={{ marginBottom: 16 }}>
<Text as={'h1'} fontSize={28} weight={'bold'}>
<TypewriterEffect
cursorCharacter={<LoadingDots size={28} variant={'pulse'} />}
cursorFade={false}
deletePauseDuration={1000}
deletingSpeed={44}
hideCursorWhileTyping={'afterTyping'}
pauseDuration={16_000}
sentences={[
t('telemetry.title', { name: 'Lobe AI' }),
t('telemetry.title2'),
t('telemetry.title3'),
]}
typingSpeed={88}
/>
</Text>
<Text as={'p'}>{t('telemetry.desc')}</Text>
</Flexbox>
<Steps
current={null as any}
direction={'vertical'}
items={[
{
description: (
<Text as={'p'} color={cssVar.colorTextSecondary} style={{ marginBottom: 16 }}>
{t('telemetry.rows.create.desc')}
</Text>
),
icon: <IconAvatar icon={PencilRulerIcon} />,
title: (
<Text as={'h2'} fontSize={16}>
{t('telemetry.rows.create.title')}
</Text>
),
},
{
description: (
<Text as={'p'} color={cssVar.colorTextSecondary} style={{ marginBottom: 16 }}>
{t('telemetry.rows.collaborate.desc')}
</Text>
),
icon: <IconAvatar icon={HeartHandshakeIcon} />,
title: (
<Text as={'h2'} fontSize={16}>
{t('telemetry.rows.collaborate.title')}
</Text>
),
},
{
description: (
<Text as={'p'} color={cssVar.colorTextSecondary}>
{t('telemetry.rows.evolve.desc')}
</Text>
),
icon: <IconAvatar icon={BrainIcon} />,
title: (
<Text as={'h2'} fontSize={16}>
{t('telemetry.rows.evolve.title')}
</Text>
),
},
]}
/>
<Button
onClick={handleNext}
size={'large'}
style={{
marginBlock: 8,
maxWidth: 240,
}}
type="primary"
>
{t('telemetry.next')}
</Button>
</Flexbox>
);
});
WelcomeStep.displayName = 'WelcomeStep';
export default WelcomeStep;
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo, useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import { electronSystemService } from '@/services/electron/system';
import OnboardingContainer from './OnboardingContainer';
import DataModeStep from './features/DataModeStep';
import LoginStep from './features/LoginStep';
import PermissionsStep from './features/PermissionsStep';
import WelcomeStep from './features/WelcomeStep';
interface DesktopOnboardingProps {
onComplete: () => void;
}
const DesktopOnboarding = memo<DesktopOnboardingProps>(({ onComplete }) => {
const [searchParams, setSearchParams] = useSearchParams();
const [isMac, setIsMac] = useState(true);
const [isLoading, setIsLoading] = useState(true);
// 从 URL query 参数获取初始步骤,默认为 1
const getInitialStep = useCallback(() => {
const stepParam = searchParams.get('step');
if (stepParam) {
const step = parseInt(stepParam, 10);
if (step >= 1 && step <= 4) return step;
}
return 1;
}, [searchParams]);
const [currentStep, setCurrentStep] = useState(getInitialStep);
// 检测平台:非 macOS 直接跳过权限页
useEffect(() => {
let mounted = true;
const detectPlatform = async () => {
try {
const state = await electronSystemService.getAppState();
if (!mounted) return;
setIsMac(state.platform === 'darwin');
} catch {
// Fallback: keep default (true)
} finally {
setIsLoading(false);
}
};
void detectPlatform();
return () => {
mounted = false;
};
}, []);
// 监听 URL query 参数变化
useEffect(() => {
const stepParam = searchParams.get('step');
if (stepParam) {
const step = parseInt(stepParam, 10);
if (step >= 1 && step <= 4 && step !== currentStep) {
setCurrentStep(step);
}
}
}, [searchParams, currentStep]);
const goToNextStep = useCallback(() => {
setCurrentStep((prev) => {
let nextStep: number;
// 如果是第1步(WelcomeStep),下一步根据平台决定
switch (prev) {
case 1: {
nextStep = isMac ? 2 : 3; // macOS 显示权限页,其他平台跳过
break;
}
case 2: {
// 如果是第2步(PermissionsStep,仅 macOS),下一步是第3步
nextStep = 3;
break;
}
case 3: {
// 如果是第3步(DataModeStep),下一步是第4步
nextStep = 4;
break;
}
case 4: {
// 如果是第4步(LoginStep),完成 onboarding
onComplete();
return prev;
}
default: {
nextStep = prev + 1;
}
}
// 更新 URL query 参数
setSearchParams({ step: nextStep.toString() });
return nextStep;
});
}, [isMac, onComplete, setSearchParams]);
const goToPreviousStep = useCallback(() => {
setCurrentStep((prev) => {
if (prev <= 1) return 1;
let prevStep: number;
// 如果当前是第3步(DataModeStep),上一步根据平台决定
if (prev === 3) {
prevStep = isMac ? 2 : 1;
} else if (prev === 2) {
// 如果当前是第2步(PermissionsStep),上一步是第1步
prevStep = 1;
} else {
prevStep = prev - 1;
}
// 更新 URL query 参数
setSearchParams({ step: prevStep.toString() });
return prevStep;
});
}, [isMac, setSearchParams]);
if (isLoading) {
return <Loading debugId="DesktopOnboarding" />;
}
const renderStep = () => {
switch (currentStep) {
case 1: {
return <WelcomeStep onNext={goToNextStep} />;
}
case 2: {
// 仅 macOS 显示权限页
if (!isMac) {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
return <PermissionsStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
case 3: {
return <DataModeStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
case 4: {
return <LoginStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
default: {
return null;
}
}
};
return (
<OnboardingContainer>
<Flexbox gap={24} style={{ maxWidth: 480, width: '100%' }}>
{renderStep()}
</Flexbox>
</OnboardingContainer>
);
});
DesktopOnboarding.displayName = 'DesktopOnboarding';
export default DesktopOnboarding;
@@ -1,321 +0,0 @@
import { createStaticStyles } from 'antd-style';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LogoBrand } from '../common/LogoBrand';
import { layoutStyles, mediaStyles, typographyStyles } from '../styles';
const styles = createStaticStyles(({ css }) => ({
logoContainer: css`
transform: translate(0, var(--logo-target-y, 0));
margin-block-end: 60px;
`,
}));
const introVideo = new URL('../assets/intro-video.mp4', import.meta.url).href;
interface Screen1Props {
onScreenConfigChange?: (config: {
background?: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
};
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}) => void;
}
export const Screen1 = ({ onScreenConfigChange }: Screen1Props) => {
const { t } = useTranslation('desktop-onboarding');
const [animationPhase, setAnimationPhase] = useState<'playing' | 'transitioning' | 'finished'>(
'playing',
);
const [videoOpacity, setVideoOpacity] = useState(1);
const [shouldStartAnimation, setShouldStartAnimation] = useState(false);
const [shouldStartFadeOut, setShouldStartFadeOut] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// 屏幕特定的动画配置
const CONFIG = {
// 动画序列配置 - 按顺序执行
animations: {
description: {
delay: 0.7, // 说明文字延迟时间 (相对于视频开始 fade)
duration: 0.7, // 说明文字动画持续时间
yOffset: 20, // 向上移动距离
},
logo: {
delay: 0.5, // Logo 出现延迟 (相对于视频开始 fade)
duration: 0.7, // Logo 动画持续时间
scale: { from: 1, to: 1 }, // Logo 缩放动画
},
logoText: {
delay: 0.3, // Logo 文字延迟出现时间 (相对于 Logo)
duration: 0.7, // Logo 文字动画持续时间
yOffset: 0, // 文字向上移动距离
},
slogan: {
delay: 0.5, // Slogan 延迟时间 (相对于视频开始 fade)
duration: 0.7, // Slogan 动画持续时间
yOffset: 20, // 向上移动距离
},
},
// 位置和尺寸控制
layout: {
// Logo 最终Y轴位置 (相对于中心)
logoTargetSize: 200,
logoTargetY: 0, // Logo 最终尺寸 (px)
logoTextHeight: 56, // Logo 文字高度 (px)
logoTextSpacing: -20, // Logo 和文字之间的间距 (px)
},
// 统一的屏幕配置
screenConfig: {
background: {
animate: true, // 启用背景动画
animationDelay: 4, // 背景延迟出现(视频完成后)
animationDuration: 1, // 背景动画持续1秒
},
navigation: {
animate: true,
// 动画持续1秒
animationDelay: 4.5,
// 启用动画
animationDuration: 1,
nextButtonText: t('screen1.navigation.next'),
showNextButton: true,
showPrevButton: false, // 延迟出现
},
},
// 视频播放控制
video: {
// 缩放动画持续时间 (秒)
endTime: 4,
// 开始渐隐的时间点 (秒)
fadeOutDuration: 1.3,
fadeStartTime: 2.7,
// 开始缩放的时间点 (秒)
scaleDuration: 2,
// 淡出动画持续时间 (秒)
scaleStartTime: 1,
// 视频最终宽度 (px)
targetHeight: 630,
// 视频完全消失的时间点 (秒)
targetWidth: 1120, // 视频最终高度 (px)
targetY: -102, // 视频最终Y轴位置 (相对于中心)
},
};
// 通知父组件屏幕配置 - 统一通知
useEffect(() => {
if (onScreenConfigChange) {
onScreenConfigChange(CONFIG.screenConfig);
}
}, [onScreenConfigChange, t]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// 监听视频播放时间
const handleTimeUpdate = () => {
const currentTime = video.currentTime;
// 开始渐隐动画
if (currentTime >= CONFIG.video.fadeStartTime && !shouldStartFadeOut) {
setShouldStartFadeOut(true);
// 开始 Motion 淡出动画
setVideoOpacity(0);
}
// 开始缩放动画
if (currentTime >= CONFIG.video.scaleStartTime && !shouldStartAnimation) {
setShouldStartAnimation(true);
}
// 显示内容 - 当开始缩放时就进入 transitioning 阶段
if (currentTime >= CONFIG.video.scaleStartTime && animationPhase === 'playing') {
setAnimationPhase('transitioning');
}
// 视频结束
if (currentTime >= CONFIG.video.endTime && animationPhase !== 'finished') {
setAnimationPhase('finished');
setVideoOpacity(0);
// 只暂停视频,不重置位置避免闪烁
video.pause();
}
};
// 如果没有视频文件,直接显示内容
const handleError = () => {
setAnimationPhase('finished');
setVideoOpacity(0);
};
// 视频结束时的处理
const handleEnded = () => {
setAnimationPhase('finished');
setVideoOpacity(0);
video.pause();
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('error', handleError);
video.addEventListener('ended', handleEnded);
// 自动播放视频
video.play().catch(() => {
// 如果自动播放失败,直接显示内容
setAnimationPhase('finished');
setVideoOpacity(0);
});
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('error', handleError);
video.removeEventListener('ended', handleEnded);
};
}, [shouldStartAnimation, shouldStartFadeOut, animationPhase]);
// @ts-ignore
return (
<motion.div
animate={{ opacity: 1 }}
className={layoutStyles.fullScreen}
exit={{ opacity: 0 }}
initial={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{/* 全屏视频 */}
{animationPhase !== 'finished' && (
<motion.div
animate={{
opacity: videoOpacity,
}}
className={layoutStyles.absolute}
transition={{
duration: CONFIG.video.fadeOutDuration,
ease: 'easeInOut',
}}
>
<motion.video
animate={{
height: shouldStartAnimation ? CONFIG.video.targetHeight : window.innerHeight,
width: shouldStartAnimation ? CONFIG.video.targetWidth : window.innerWidth,
x: shouldStartAnimation ? (window.innerWidth - CONFIG.video.targetWidth) / 2 : 0,
y: shouldStartAnimation
? (window.innerHeight - CONFIG.video.targetHeight) / 2 + CONFIG.video.targetY
: 0,
}}
className={mediaStyles.responsiveVideo}
initial={{
height: window.innerHeight,
width: window.innerWidth,
x: 0,
y: 0,
}}
muted
playsInline
ref={videoRef}
src={introVideo}
transition={{
duration: CONFIG.video.scaleDuration,
ease: 'easeInOut',
}}
/>
</motion.div>
)}
{/* Logo 和文字内容 */}
<AnimatePresence>
{shouldStartFadeOut && (
<motion.div
animate={{ opacity: 1 }}
className={layoutStyles.centered}
initial={{ opacity: 0 }}
transition={{ duration: 1 }}
>
{/* Logo 组合 */}
<div
className={styles.logoContainer}
style={{
// @ts-ignore
'--logo-target-y': `${CONFIG.layout.logoTargetY}px`,
}}
>
<LogoBrand
animated={true}
animation={{
logo: {
delay: CONFIG.animations.logo.delay,
duration: CONFIG.animations.logo.duration,
scale: CONFIG.animations.logo.scale,
},
logoText: {
delay: CONFIG.animations.logoText.delay,
duration: CONFIG.animations.logoText.duration,
yOffset: CONFIG.animations.logoText.yOffset,
},
}}
logoSize={CONFIG.layout.logoTargetSize}
spacing={CONFIG.layout.logoTextSpacing}
textHeight={CONFIG.layout.logoTextHeight}
/>
</div>
{/* Slogan */}
<motion.h1
animate={{ opacity: 1, y: 0 }}
className={typographyStyles.heroTitle}
initial={{
opacity: 0,
y: CONFIG.animations.slogan.yOffset,
}}
transition={{
delay: CONFIG.animations.slogan.delay,
duration: CONFIG.animations.slogan.duration,
}}
>
{t('screen1.slogan.line1')} <br />
{t('screen1.slogan.line2')}
</motion.h1>
{/* 说明文字 */}
<motion.p
animate={{ opacity: 1, y: 0 }}
className={typographyStyles.body}
initial={{
opacity: 0,
y: CONFIG.animations.description.yOffset,
}}
transition={{
delay: CONFIG.animations.description.delay,
duration: CONFIG.animations.description.duration,
}}
>
{t('screen1.description')}
</motion.p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
@@ -1,236 +0,0 @@
import { MCP } from '@lobehub/icons';
import { createStaticStyles, cx } from 'antd-style';
import { Flower, Globe, Image, RefreshCw } from 'lucide-react';
import { motion } from 'motion/react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TitleSection } from '../common/TitleSection';
import { layoutStyles } from '../styles';
import { getThemeToken } from '../styles/theme';
const themeToken = getThemeToken();
// Screen2 特有的样式
const styles = createStaticStyles(({ css, cssVar }) => ({
cardContent: css`
position: relative;
z-index: 2;
`,
// 网格布局
cardGrid: css`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
`,
// 图标和小标题行
cardHeader: css`
display: flex;
gap: 8px;
align-items: center;
margin-block-end: 8px;
`,
cardIcon: css`
width: 20px;
height: 20px;
color: var(--icon-color, currentColor);
`,
// 径向渐变蒙层
cardOverlay: css`
position: absolute;
z-index: 1;
inset: 0;
background: radial-gradient(
ellipse 150% 100% at 10% 10%,
rgba(0, 0, 0, 90%) 0%,
rgba(0, 0, 0, 80%) 35%,
rgba(0, 0, 0, 20%) 60%,
transparent 85%
);
`,
cardSubtitle: css`
font-size: ${cssVar.fontSize};
font-weight: 500;
color: rgba(255, 255, 255, 80%);
`,
cardTitle: css`
margin: 0;
font-family: ${cssVar.fontFamily};
font-size: 24px;
font-weight: 500;
line-height: 1.3;
color: ${themeToken.colorTextBase};
text-align: start;
`,
// 卡片样式
featureCard: css`
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: ${cssVar.paddingLG};
border-radius: ${cssVar.borderRadiusLG};
background: ${themeToken.colorBgBase};
background-image: var(--card-bg-image, none);
background-repeat: no-repeat;
background-position: right bottom;
background-size: cover;
outline: 1px solid rgba(255, 255, 255, 10%);
`,
}));
const cardBg1 = new URL('../assets/card-bg-1.webp', import.meta.url).href;
const cardBg2 = new URL('../assets/card-bg-2.webp', import.meta.url).href;
const cardBg3 = new URL('../assets/card-bg-3.webp', import.meta.url).href;
const cardBg4 = new URL('../assets/card-bg-4.webp', import.meta.url).href;
const cardBg5 = new URL('../assets/card-bg-5.webp', import.meta.url).href;
const cardBg6 = new URL('../assets/card-bg-6.webp', import.meta.url).href;
// 卡片数据(文案由 i18n 提供)
const featureMetas = [
{
backgroundImage: cardBg1,
color: themeToken.colorPurple,
icon: Image,
id: 1,
},
{
backgroundImage: cardBg2,
color: themeToken.colorYellow,
icon: MCP,
id: 2,
},
{
backgroundImage: cardBg3,
color: themeToken.colorBlue,
icon: Globe,
id: 3,
},
{
backgroundImage: cardBg4,
color: themeToken.colorBlue,
icon: RefreshCw,
id: 4,
},
{
backgroundImage: cardBg5,
color: themeToken.colorGreen,
icon: Flower,
id: 5,
},
{
backgroundImage: cardBg6,
color: themeToken.colorPurple,
icon: RefreshCw,
id: 6,
},
] as const;
interface Screen2Props {
onScreenConfigChange?: (config: {
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}) => void;
}
export const Screen2 = ({ onScreenConfigChange }: Screen2Props) => {
const { t } = useTranslation('desktop-onboarding');
// 通知父组件屏幕配置
useEffect(() => {
if (onScreenConfigChange) {
// 屏幕特定的配置
const CONFIG = {
screenConfig: {
navigation: {
animate: false,
showNextButton: true,
showPrevButton: true,
},
},
};
onScreenConfigChange(CONFIG.screenConfig);
}
}, [onScreenConfigChange, t]);
return (
<div className={layoutStyles.fullScreen}>
{/* 内容层 */}
<div className={layoutStyles.centered}>
{/* 标题部分 */}
<TitleSection
animated={true}
badge={t('screen2.badge')}
description={t('screen2.description')}
title={t('screen2.title')}
/>
{/* 卡片网格 */}
<div className={cx(styles.cardGrid, layoutStyles.contentSection)}>
{featureMetas.map((feature, index) => {
const cssVariables: Record<string, string> = {
'--card-bg-image': `url(${feature.backgroundImage})`,
'--icon-color': feature.color,
};
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={styles.featureCard}
initial={{ opacity: 0, y: 50 }}
key={feature.id}
style={cssVariables}
transition={{
delay: 0.2 + index * 0.1,
duration: 0.3,
ease: 'easeOut',
}}
>
{/* 径向渐变蒙层 */}
<div className={styles.cardOverlay} />
{/* 内容 */}
<div className={styles.cardContent}>
{/* 图标和小标题行 */}
<div className={styles.cardHeader}>
<feature.icon className={styles.cardIcon} />
<span className={styles.cardSubtitle}>
{t(`screen2.features.${feature.id}.subtitle`)}
</span>
</div>
{/* 大标题 */}
<h3
className={styles.cardTitle}
dangerouslySetInnerHTML={{
__html: t(`screen2.features.${feature.id}.title`),
}}
/>
</div>
</motion.div>
);
})}
</div>
</div>
</div>
);
};
@@ -1,383 +0,0 @@
import { createStaticStyles, cx } from 'antd-style';
import { Bell, Check, FolderOpen, Mic, MonitorCog } from 'lucide-react';
import { motion } from 'motion/react';
import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ensureElectronIpc } from '@/utils/electron/ipc';
import { TitleSection } from '../common/TitleSection';
import { layoutStyles } from '../styles';
import { getThemeToken } from '../styles/theme';
const themeToken = getThemeToken();
// Screen3 特有的样式
const screen3Styles = createStaticStyles(({ css, cssVar }) => ({
// 内容区
content: css`
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
`,
// 图标样式
icon: css`
color: var(--permission-icon-color, currentColor);
`,
// 图标容器
iconWrapper: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: transparent;
`,
// 项目描述
itemDescription: css`
margin: 0;
font-size: ${cssVar.fontSize};
line-height: 1.5;
color: rgba(255, 255, 255, 60%);
`,
// 项目标题
itemTitle: css`
margin: 0;
font-size: ${cssVar.fontSizeLG};
font-weight: 500;
color: ${themeToken.colorTextBase};
`,
// 按钮
permissionButton: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
min-width: 170px;
padding-block: 10px;
padding-inline: 20px;
border: 1px solid rgba(255, 255, 255, 20%);
border-radius: 8px;
font-size: ${cssVar.fontSize};
font-weight: 700;
color: ${themeToken.colorTextBase};
white-space: nowrap;
background: rgba(255, 255, 255, 10%);
transition: all 0.2s ease;
&:hover {
border-color: rgba(255, 255, 255, 30%);
background: rgba(255, 255, 255, 15%);
}
&:active {
transform: scale(0.98);
}
&.granted {
cursor: not-allowed;
border-color: ${themeToken.colorGreen};
/* Use currentColor so the icon and text both become "success green" */
color: ${themeToken.colorGreen};
opacity: 1;
background: color-mix(in srgb, ${themeToken.colorGreen} 12%, transparent);
&:hover {
transform: none;
border-color: ${themeToken.colorGreen};
background: color-mix(in srgb, ${themeToken.colorGreen} 12%, transparent);
}
}
`,
// 列表项
permissionItem: css`
display: flex;
gap: 20px;
align-items: center;
padding-block: 20px;
padding-inline: 24px;
border: 1px solid rgba(255, 255, 255, 10%);
border-radius: ${cssVar.borderRadiusLG};
background: rgba(255, 255, 255, 4%);
backdrop-filter: blur(20px);
transition:
background-color 0.5s ease,
border-color 0.5s ease;
&:hover {
border-color: rgba(255, 255, 255, 15%);
background: rgba(255, 255, 255, 8%);
}
`,
// 列表容器
permissionList: css`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 800px;
font-family: ${cssVar.fontFamily};
`,
}));
const permissionMetas = [
{
descriptionKey: 'screen3.permissions.1.description',
icon: Bell,
iconColor: themeToken.colorYellow,
id: 1,
titleKey: 'screen3.permissions.1.title',
},
{
descriptionKey: 'screen3.permissions.2.description',
icon: FolderOpen,
iconColor: themeToken.colorGreen,
id: 2,
titleKey: 'screen3.permissions.2.title',
},
{
descriptionKey: 'screen3.permissions.3.description',
icon: Mic,
iconColor: themeToken.colorBlue,
id: 3,
titleKey: 'screen3.permissions.3.title',
},
{
descriptionKey: 'screen3.permissions.4.description',
icon: MonitorCog,
iconColor: themeToken.colorPurple,
id: 4,
titleKey: 'screen3.permissions.4.title',
},
] as const;
type PermissionMeta = (typeof permissionMetas)[number];
type PermissionButtonKey = 'screen3.actions.grantAccess' | 'screen3.actions.openSettings';
type PermissionItem = PermissionMeta & {
buttonKey: PermissionButtonKey;
granted: boolean;
};
interface Screen3Props {
onScreenConfigChange?: (config: {
background?: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
};
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}) => void;
}
export const Screen3 = ({ onScreenConfigChange }: Screen3Props) => {
const { t } = useTranslation('desktop-onboarding');
// 屏幕特定的配置
const CONFIG = {
screenConfig: {
navigation: {
animate: false,
showNextButton: true,
showPrevButton: true,
},
},
};
const [permissions, setPermissions] = useState<PermissionItem[]>(() =>
permissionMetas.map((p) => ({
...p,
buttonKey: 'screen3.actions.grantAccess',
granted: false,
})),
);
const checkAllPermissions = useCallback(async () => {
const ipc = ensureElectronIpc();
if (!ipc) return;
const state = await ipc.system.getAppState();
const isMac = state.platform === 'darwin';
if (!isMac) {
// If not on macOS, assume all permissions are granted
setPermissions((prev) => prev.map((p) => ({ ...p, granted: true })));
return;
}
const notifStatus = await ipc.notification.getNotificationPermissionStatus();
const micStatus = await ipc.system.getMediaAccessStatus('microphone');
const screenStatus = await ipc.system.getMediaAccessStatus('screen');
const accessibilityStatus = await ipc.system.getAccessibilityStatus();
setPermissions((prev) =>
prev.map((p) => {
if (p.id === 1) return { ...p, granted: notifStatus === 'authorized' };
// Full Disk Access cannot be checked programmatically, so it remains manual
if (p.id === 2) return { ...p, buttonKey: 'screen3.actions.openSettings', granted: false };
if (p.id === 3)
return { ...p, granted: micStatus === 'granted' && screenStatus === 'granted' };
if (p.id === 4) return { ...p, granted: accessibilityStatus };
return p;
}),
);
}, []);
useEffect(() => {
checkAllPermissions();
}, [checkAllPermissions]);
// When this page regains focus (e.g. back from System Settings), re-check permission states and refresh UI.
useEffect(() => {
if (typeof window === 'undefined') return;
const handleFocus = () => {
checkAllPermissions();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkAllPermissions();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [checkAllPermissions]);
const handlePermissionRequest = async (permissionId: number) => {
const ipc = ensureElectronIpc();
if (!ipc) return;
switch (permissionId) {
case 1: {
await ipc.notification.requestNotificationPermission();
break;
}
case 2: {
await ipc.system.openFullDiskAccessSettings({ autoAdd: true });
break;
}
case 3: {
await ipc.system.requestMicrophoneAccess();
await ipc.system.requestScreenAccess();
break;
}
case 4: {
await ipc.system.requestAccessibilityAccess();
break;
}
default: {
break;
}
}
// Re-check permissions after a short delay to allow system dialogs
setTimeout(() => {
void checkAllPermissions();
}, 1000);
};
// 通知父组件屏幕配置
useEffect(() => {
if (onScreenConfigChange) {
onScreenConfigChange(CONFIG.screenConfig);
}
}, [onScreenConfigChange]);
return (
<div className={layoutStyles.fullScreen}>
{/* 内容层 */}
<div className={layoutStyles.centered}>
{/* 标题部分 */}
<TitleSection
animated={true}
badge={t('screen3.badge')}
description={t('screen3.description')}
title={t('screen3.title')}
/>
{/* 权限列表 */}
<div className={screen3Styles.permissionList}>
{permissions.map((permission, index) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={screen3Styles.permissionItem}
initial={{ opacity: 0, y: 50 }}
key={permission.id}
transition={{
delay: 0.1 + index * 0.1,
duration: 0.5,
ease: [0.4, 0, 0.2, 1],
}}
>
{/* 图标 */}
<div
className={screen3Styles.iconWrapper}
style={{ '--permission-icon-color': permission.iconColor } as CSSProperties}
>
<permission.icon className={screen3Styles.icon} size={24} />
</div>
{/* 内容 */}
<div className={screen3Styles.content}>
<h3 className={screen3Styles.itemTitle}>{t(permission.titleKey)}</h3>
<p className={screen3Styles.itemDescription}>{t(permission.descriptionKey)}</p>
</div>
{/* 按钮 */}
<button
className={cx(screen3Styles.permissionButton, permission.granted && 'granted')}
disabled={permission.granted && permission.id !== 2}
onClick={() => handlePermissionRequest(permission.id)}
type="button"
>
{permission.granted && permission.id !== 2 ? (
<>
<Check size={16} />
{t('screen3.actions.granted')}
</>
) : (
t(permission.buttonKey)
)}
</button>
</motion.div>
))}
</div>
</div>
</div>
);
};
@@ -1,294 +0,0 @@
import { createStaticStyles, cx } from 'antd-style';
import { CheckCircle, HeartHandshake, Shield } from 'lucide-react';
import { motion } from 'motion/react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { TitleSection } from '../common/TitleSection';
import { layoutStyles } from '../styles';
import { getThemeToken } from '../styles/theme';
const themeToken = getThemeToken();
// Screen4 特有的样式
const screen4Styles = createStaticStyles(({ css, cssVar }) => ({
// 卡片描述
cardDescription: css`
margin-block-end: 24px;
font-size: ${cssVar.fontSize};
line-height: 1.5;
color: rgba(255, 255, 255, 60%);
text-align: center;
`,
// 卡片头部
cardHeader: css`
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
margin-block-end: 8px;
`,
// 卡片标题
cardTitle: css`
margin: 0;
font-family: ${cssVar.fontFamily};
font-size: 20px;
font-weight: 700;
color: ${themeToken.colorTextBase};
text-align: center;
`,
// 选中标记
checkIcon: css`
position: absolute;
inset-block-start: 20px;
inset-inline-end: 20px;
color: ${themeToken.colorGreen};
opacity: 0;
transition: opacity 0.3s ease;
`,
// 内容区容器
contentContainer: css`
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
max-width: 900px;
margin-block: 0;
margin-inline: auto;
`,
// 特性列表
featureList: css`
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
li {
position: relative;
padding-inline-start: 20px;
font-size: ${cssVar.fontSize};
line-height: 1.5;
color: rgb(255, 255, 255);
&::before {
content: '•';
position: absolute;
inset-inline-start: 0;
color: rgba(255, 255, 255, 40%);
}
&:last-child {
margin-block-end: 0;
}
}
`,
// 底部说明文字
footerNote: css`
margin-block-start: 24px;
font-size: ${cssVar.fontSize};
color: rgba(255, 255, 255, 50%);
text-align: center;
`,
// 数据选项卡片
optionCard: css`
cursor: pointer;
position: relative;
flex: 1;
max-width: 400px;
padding-block: 32px;
padding-inline: 28px;
border: 1px solid rgba(255, 255, 255, 10%);
border-radius: ${cssVar.borderRadiusLG};
background: rgba(255, 255, 255, 2%);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 15%);
background: rgba(255, 255, 255, 4%);
}
&.selected {
border-color: ${themeToken.colorGreen};
background: rgba(255, 255, 255, 8%);
.check-icon {
opacity: 1;
}
}
`,
// 选项卡容器
optionsContainer: css`
display: flex;
gap: 16px;
justify-content: center;
width: 100%;
`,
}));
type DataMode = 'share' | 'privacy';
interface Screen4Props {
onScreenConfigChange?: (config: {
background?: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
};
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}) => void;
}
export const Screen4 = ({ onScreenConfigChange }: Screen4Props) => {
const { t } = useTranslation('desktop-onboarding');
const telemetryEnabled = useUserStore(userGeneralSettingsSelectors.telemetry);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const selectedMode: DataMode = telemetryEnabled ? 'share' : 'privacy';
// 屏幕特定的配置
const CONFIG = {
screenConfig: {
navigation: {
animate: false,
nextButtonText: t('screen4.navigation.next'),
showNextButton: true,
showPrevButton: true,
},
},
};
const setMode = useCallback(
(mode: DataMode) => {
const nextTelemetry = mode === 'share';
if (telemetryEnabled === nextTelemetry) return;
void updateGeneralConfig({ telemetry: nextTelemetry });
},
[telemetryEnabled, updateGeneralConfig],
);
// 通知父组件屏幕配置
useEffect(() => {
if (onScreenConfigChange) {
onScreenConfigChange(CONFIG.screenConfig);
}
}, [onScreenConfigChange, t]);
return (
<div className={layoutStyles.fullScreen}>
{/* 内容层 */}
<div className={layoutStyles.centered}>
{/* 标题部分 */}
<TitleSection
animated={true}
badge={t('screen4.badge')}
description={t('screen4.description')}
title={t('screen4.title')}
/>
{/* 选项卡区域 */}
<div className={screen4Styles.contentContainer}>
<motion.div
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.optionsContainer}
initial={{ opacity: 0, y: 20 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
{/* 共享数据选项 */}
<div
className={cx(screen4Styles.optionCard, selectedMode === 'share' && 'selected')}
onClick={() => setMode('share')}
>
<CheckCircle className={cx(screen4Styles.checkIcon, 'check-icon')} size={24} />
<div className={screen4Styles.cardHeader}>
<HeartHandshake color={themeToken.colorGreen} size={48} />
<h3 className={screen4Styles.cardTitle}>{t('screen4.share.title')}</h3>
</div>
<p className={screen4Styles.cardDescription}>
{t('screen4.share.description')}
</p>
<ul className={screen4Styles.featureList}>
<li>{t('screen4.share.items.1')}</li>
<li>{t('screen4.share.items.2')}</li>
<li>{t('screen4.share.items.3')}</li>
</ul>
</div>
{/* 隐私模式选项 */}
<div
className={cx(screen4Styles.optionCard, selectedMode === 'privacy' && 'selected')}
onClick={() => setMode('privacy')}
>
<CheckCircle className={cx(screen4Styles.checkIcon, 'check-icon')} size={24} />
<div className={screen4Styles.cardHeader}>
<Shield color={themeToken.colorBlue} size={48} />
<h3 className={screen4Styles.cardTitle}>{t('screen4.privacy.title')}</h3>
</div>
<p className={screen4Styles.cardDescription}>
{t('screen4.privacy.description')}
</p>
<ul className={screen4Styles.featureList}>
<li>{t('screen4.privacy.items.1')}</li>
<li>{t('screen4.privacy.items.2')}</li>
<li>{t('screen4.privacy.items.3')}</li>
</ul>
</div>
</motion.div>
{/* 底部说明 */}
<motion.p
animate={{ opacity: 1 }}
className={screen4Styles.footerNote}
initial={{ opacity: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
>
{t('screen4.footerNote')}
</motion.p>
</div>
</div>
</div>
);
};
@@ -1,787 +0,0 @@
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { createStaticStyles, cx } from 'antd-style';
import { Cloud, Server } from 'lucide-react';
import { motion } from 'motion/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { useElectronStore } from '@/store/electron';
import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
import { AuthResult } from '../common/AuthResult';
import { LogoBrand } from '../common/LogoBrand';
import { TitleSection } from '../common/TitleSection';
import { layoutStyles } from '../styles';
import { getThemeToken } from '../styles/theme';
const themeToken = getThemeToken();
// Screen4 特有的样式
const screen4Styles = createStaticStyles(({ css, cssVar }) => ({
// 授权说明文字
authDescription: css`
margin: 0;
margin-block-end: 32px;
font-size: ${cssVar.fontSize};
color: rgba(255, 255, 255, 60%);
text-align: center;
`,
// 内容区容器
contentContainer: css`
display: flex;
flex-direction: column;
align-items: center;
`,
// 禁用状态按钮样式
disabledButton: css`
cursor: not-allowed;
opacity: 0.5;
`,
// Endpoint输入框
endpointInput: css`
width: 100%;
margin-block: 16px;
margin-inline: 0;
padding-block: 12px;
padding-inline: 16px;
border: 1px solid rgba(255, 255, 255, 10%);
border-radius: ${cssVar.borderRadius};
font-size: ${cssVar.fontSize};
color: #fff;
background: rgba(255, 255, 255, 5%);
outline: none;
&:focus {
border-color: rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 8%);
}
`,
errorText: css`
max-width: 520px;
margin: 0;
margin-block-start: 16px;
font-size: ${cssVar.fontSizeSM};
color: ${cssVar.colorError};
text-align: center;
word-break: break-word;
white-space: pre-wrap;
`,
// 加载状态按钮样式
loadingButton: css`
cursor: not-allowed;
opacity: 0.7;
`,
loginContentBody: css`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 500px;
`,
loginContentHeader: css`
display: flex;
flex-direction: column;
align-items: center;
`,
// 登录内容包装器(固定高度)
loginContentWrapper: css`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 360px;
`,
// 登录方式选项卡片
methodCard: css`
cursor: pointer;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
min-width: 140px;
padding-block: 10px;
padding-inline: 20px;
border: 1px solid rgba(255, 255, 255, 10%);
border-radius: 8px;
background: rgba(255, 255, 255, 2%);
transition: all 0.3s ease;
svg {
color: rgba(255, 255, 255, 70%);
}
&:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 5%);
svg {
color: rgba(255, 255, 255, 90%);
}
}
&.active {
border-color: rgba(255, 255, 255, 30%);
background: rgba(255, 255, 255, 8%);
svg {
color: rgb(255, 255, 255);
}
span {
color: rgb(255, 255, 255);
}
}
`,
// 方法卡片文字
methodCardText: css`
font-size: ${cssVar.fontSize};
font-weight: 600;
color: rgba(255, 255, 255, 90%);
white-space: nowrap;
`,
// 登录方式选项容器
methodOptions: css`
display: flex;
gap: 12px;
justify-content: center;
width: 100%;
`,
// 登录方式选择区域
methodSelector: css`
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
width: 100%;
margin-block-start: 48px;
`,
// 登录方式标题
methodSelectorTitle: css`
margin-block-end: 8px;
font-size: ${cssVar.fontSize};
color: rgba(255, 255, 255, 60%);
`,
// 服务名称标题
serviceTitle: css`
margin-block: 16px;
margin-block-end: 8px;
margin-inline: 0;
font-family: ${cssVar.fontFamily};
font-size: 32px;
font-weight: 500;
color: ${cssVar.colorTextBase};
`,
// 登录按钮
signInButton: css`
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding-block: 12px;
padding-inline: 32px;
border: none;
border-radius: ${cssVar.borderRadius};
font-size: ${cssVar.fontSize};
font-weight: 700;
color: #000;
background: ${themeToken.colorHighlight};
&:hover {
transform: translateY(-1px);
background: ${themeToken.colorHighlightHover};
}
&:active {
transform: translateY(0);
}
`,
// 退出登录按钮(次要操作)
signOutButton: css`
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
margin-block-start: 16px;
padding-block: 10px;
padding-inline: 28px;
border: 1px solid rgba(255, 255, 255, 18%);
border-radius: ${cssVar.borderRadius};
font-size: ${cssVar.fontSize};
font-weight: 600;
color: rgba(255, 255, 255, 85%);
background: rgba(255, 255, 255, 4%);
&:hover {
background: rgba(255, 255, 255, 8%);
}
&:active {
background: rgba(255, 255, 255, 6%);
}
`,
}));
// 登录方式类型
type LoginMethod = 'cloud' | 'selfhost';
// 登录状态类型
type LoginStatus = 'idle' | 'loading' | 'success' | 'error';
const loginMethodMetas = {
cloud: {
descriptionKey: 'screen5.methods.cloud.description',
icon: Cloud,
id: 'cloud' as LoginMethod,
nameKey: 'screen5.methods.cloud.name',
},
selfhost: {
descriptionKey: 'screen5.methods.selfhost.description',
icon: Server,
id: 'selfhost' as LoginMethod,
nameKey: 'screen5.methods.selfhost.name',
},
} as const satisfies Record<LoginMethod, unknown>;
interface Screen5Props {
onScreenConfigChange?: (config: {
background?: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
};
navigation: {
animate?: boolean;
animationDelay?: number;
animationDuration?: number;
nextButtonText?: string;
prevButtonText?: string;
showNextButton?: boolean;
showPrevButton?: boolean;
};
}) => void;
}
export const Screen5 = ({ onScreenConfigChange }: Screen5Props) => {
const { t } = useTranslation('desktop-onboarding');
const [currentMethod, setCurrentMethod] = useState<LoginMethod>('cloud');
const [endpoint, setEndpoint] = useState('');
const [cloudLoginStatus, setCloudLoginStatus] = useState<LoginStatus>('idle');
const [selfhostLoginStatus, setSelfhostLoginStatus] = useState<LoginStatus>('idle');
const [remoteError, setRemoteError] = useState<string | null>(null);
const [isSigningOut, setIsSigningOut] = useState(false);
const [
dataSyncConfig,
isConnectingServer,
remoteServerSyncError,
useDataSyncConfig,
connectRemoteServer,
refreshServerConfig,
clearRemoteServerSyncError,
disconnectRemoteServer,
] = useElectronStore((s) => [
s.dataSyncConfig,
s.isConnectingServer,
s.remoteServerSyncError,
s.useDataSyncConfig,
s.connectRemoteServer,
s.refreshServerConfig,
s.clearRemoteServerSyncError,
s.disconnectRemoteServer,
]);
// Ensure remote server config is loaded early (desktop only hook)
useDataSyncConfig();
const isCloudAuthed = !!dataSyncConfig?.active && dataSyncConfig.storageMode === 'cloud';
const isSelfHostAuthed = !!dataSyncConfig?.active && dataSyncConfig.storageMode === 'selfHost';
const isSelfHostEndpointVerified =
isSelfHostAuthed &&
!!endpoint.trim() &&
endpoint.trim() === (dataSyncConfig?.remoteServerUrl ?? '');
// 判断是否可以开始使用
const canStart = () => {
switch (currentMethod) {
case 'cloud': {
return isCloudAuthed || cloudLoginStatus === 'success';
}
case 'selfhost': {
// For self-host, require endpoint input AND verification completed for that endpoint.
return isSelfHostEndpointVerified;
}
default: {
return false;
}
}
};
// 屏幕特定的配置
const CONFIG = {
screenConfig: {
navigation: {
animate: false,
nextButtonDisabled: !canStart(),
nextButtonHighlight: true,
nextButtonText: t('screen5.navigation.next'),
showNextButton: true,
showPrevButton: true,
},
},
};
// 通知父组件屏幕配置
useEffect(() => {
if (onScreenConfigChange) {
onScreenConfigChange(CONFIG.screenConfig);
}
}, [onScreenConfigChange, currentMethod, cloudLoginStatus, selfhostLoginStatus, t]);
// 处理登录方式切换
const handleMethodChange = (method: LoginMethod) => {
setCurrentMethod(method);
// For self-host, prefill saved remoteServerUrl so "verified endpoint" can be determined.
setEndpoint(method === 'selfhost' ? (dataSyncConfig?.remoteServerUrl ?? '') : '');
// 重置登录状态
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setRemoteError(null);
clearRemoteServerSyncError();
};
// 处理云端登录
const handleCloudLogin = async () => {
// Desktop runtime guard
if (!isDesktop) {
setRemoteError(t('screen5.errors.desktopOnlyOidc'));
setCloudLoginStatus('error');
return;
}
setRemoteError(null);
clearRemoteServerSyncError();
setCloudLoginStatus('loading');
// Keep consistent with `DesktopAutoOidcOnFirstOpen`: mark as handled to prevent a second auto prompt.
setDesktopAutoOidcFirstOpenHandled();
await connectRemoteServer({
remoteServerUrl: dataSyncConfig?.remoteServerUrl,
storageMode: 'cloud',
});
};
// 处理自建服务器连接
const handleSelfhostConnect = async () => {
// Desktop runtime guard
if (!isDesktop) {
setRemoteError(t('screen5.errors.desktopOnlyOidc'));
setSelfhostLoginStatus('error');
return;
}
const url = endpoint.trim();
if (!url) return;
setRemoteError(null);
clearRemoteServerSyncError();
setSelfhostLoginStatus('loading');
await connectRemoteServer({ remoteServerUrl: url, storageMode: 'selfHost' });
};
// 返回到登录界面
const handleReturnToLogin = () => {
setRemoteError(null);
clearRemoteServerSyncError();
switch (currentMethod) {
case 'cloud': {
setCloudLoginStatus('idle');
break;
}
case 'selfhost': {
setSelfhostLoginStatus('idle');
break;
}
}
};
// 退出登录(断开远程同步授权)并回到登录选择
const handleSignOut = async () => {
if (isSigningOut) return;
setIsSigningOut(true);
setRemoteError(null);
clearRemoteServerSyncError();
try {
await disconnectRemoteServer();
await refreshServerConfig();
} finally {
setCloudLoginStatus('idle');
setSelfhostLoginStatus('idle');
setEndpoint('');
setIsSigningOut(false);
}
};
// Sync local UI status with real remote config
useEffect(() => {
if (isCloudAuthed) setCloudLoginStatus('success');
if (isSelfHostEndpointVerified) setSelfhostLoginStatus('success');
}, [isCloudAuthed, isSelfHostEndpointVerified]);
// If user changes self-host endpoint after success, require re-authorization.
useEffect(() => {
if (currentMethod !== 'selfhost') return;
if (selfhostLoginStatus !== 'success') return;
if (isSelfHostEndpointVerified) return;
setSelfhostLoginStatus('idle');
}, [currentMethod, isSelfHostEndpointVerified, selfhostLoginStatus]);
// Surface requestAuthorization errors reported via store
useEffect(() => {
const message = remoteServerSyncError?.message;
if (!message) return;
setRemoteError(message);
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
}, [remoteServerSyncError?.message, cloudLoginStatus, selfhostLoginStatus]);
// Watch broadcasts from main process (polling result)
useWatchBroadcast('authorizationSuccessful', async () => {
setRemoteError(null);
clearRemoteServerSyncError();
await refreshServerConfig();
});
useWatchBroadcast('authorizationFailed', ({ error }) => {
setRemoteError(error);
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
});
// 判断是否应该显示登录结果(隐藏下拉菜单)
const shouldShowResult = () => {
switch (currentMethod) {
case 'cloud': {
return cloudLoginStatus === 'success' || cloudLoginStatus === 'error';
} // 成功或失败都隐藏下拉菜单
case 'selfhost': {
return selfhostLoginStatus === 'success' || selfhostLoginStatus === 'error';
} // 成功或失败都隐藏下拉菜单
default: {
return false;
}
}
};
// 渲染不同登录方式的内容
const renderLoginContent = () => {
const method = loginMethodMetas[currentMethod];
const methodName = t(method.nameKey);
const methodDescription = t(method.descriptionKey);
switch (currentMethod) {
case 'cloud': {
// 如果已有登录结果,显示结果页面
if (cloudLoginStatus === 'success') {
return (
<>
<AuthResult animated={true} key="cloud-result" success={true} />
{remoteError && <p className={screen4Styles.errorText}>{remoteError}</p>}
<motion.button
animate={{ opacity: 1, y: 0 }}
className={cx(
screen4Styles.signOutButton,
(isSigningOut || isConnectingServer) && screen4Styles.loadingButton,
)}
disabled={isSigningOut || isConnectingServer}
initial={{ opacity: 0, y: 30 }}
key="cloud-signout"
onClick={handleSignOut}
transition={{ delay: 0.3, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</motion.button>
</>
);
}
// 如果登录失败,显示失败结果但允许重新登录
if (cloudLoginStatus === 'error') {
return (
<>
<AuthResult animated={true} key="cloud-error" success={false} />
{remoteError && <p className={screen4Styles.errorText}>{remoteError}</p>}
{/* 重新登录按钮 */}
<motion.button
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.signInButton}
initial={{ opacity: 0, y: 30 }}
key="cloud-retry"
onClick={handleReturnToLogin}
transition={{ delay: 0.3, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{t('screen5.actions.tryAgain')}
</motion.button>
</>
);
}
return (
<>
{/* LobeHub Logo 品牌 */}
<motion.div
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.loginContentHeader}
initial={{ opacity: 0, y: 30 }}
key="cloud-logo"
transition={{ delay: 0.3, duration: 0.5 }}
>
<LogoBrand animated={false} logoSize={200} spacing={-20} textHeight={56} />
</motion.div>
{/* 授权说明 */}
<motion.p
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.authDescription}
initial={{ opacity: 0, y: 30 }}
key="cloud-desc"
transition={{ delay: 0.4, duration: 0.5 }}
>
{methodDescription}
</motion.p>
{/* 登录按钮 */}
<motion.button
animate={{ opacity: 1, y: 0 }}
className={cx(
screen4Styles.signInButton,
cloudLoginStatus === 'loading' && screen4Styles.loadingButton,
)}
disabled={cloudLoginStatus === 'loading' || isConnectingServer}
initial={{ opacity: 0, y: 30 }}
key="cloud-button"
onClick={handleCloudLogin}
transition={{ delay: 0.5, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{cloudLoginStatus === 'loading'
? t('screen5.actions.signingIn')
: t('screen5.actions.signInCloud')}
</motion.button>
</>
);
}
case 'selfhost': {
// 如果连接成功,显示成功结果页面
if (selfhostLoginStatus === 'success') {
return (
<>
<AuthResult animated={true} key="selfhost-result" success={true} />
{remoteError && <p className={screen4Styles.errorText}>{remoteError}</p>}
<motion.button
animate={{ opacity: 1, y: 0 }}
className={cx(
screen4Styles.signOutButton,
(isSigningOut || isConnectingServer) && screen4Styles.loadingButton,
)}
disabled={isSigningOut || isConnectingServer}
initial={{ opacity: 0, y: 30 }}
key="selfhost-signout"
onClick={handleSignOut}
transition={{ delay: 0.3, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</motion.button>
</>
);
}
// 如果连接失败,显示失败结果但允许重新连接
if (selfhostLoginStatus === 'error') {
return (
<>
<AuthResult animated={true} key="selfhost-error" success={false} />
{remoteError && <p className={screen4Styles.errorText}>{remoteError}</p>}
{/* 重新连接按钮 */}
<motion.button
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.signInButton}
initial={{ opacity: 0, y: 30 }}
key="selfhost-retry"
onClick={handleReturnToLogin}
transition={{ delay: 0.3, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{t('screen5.actions.tryAgain')}
</motion.button>
</>
);
}
return (
<>
{/* Self-host 图标和标题 */}
<motion.div
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.loginContentHeader}
initial={{ opacity: 0, y: 30 }}
key="selfhost-header"
transition={{ delay: 0.3, duration: 0.5 }}
>
<Server color={themeToken.colorGreen} size={80} />
<h2 className={screen4Styles.serviceTitle}>{methodName}</h2>
<p className={screen4Styles.authDescription}>{methodDescription}</p>
</motion.div>
{/* Endpoint 输入框 */}
<div className={screen4Styles.loginContentBody}>
<motion.input
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.endpointInput}
disabled={selfhostLoginStatus === 'loading'}
initial={{ opacity: 0, y: 30 }}
key="selfhost-input"
onChange={(e) => setEndpoint(e.target.value)}
onContextMenu={async (e) => {
if (!isDesktop) return;
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
await electronSystemService.showContextMenu('edit');
}}
placeholder={t('screen5.selfhost.endpointPlaceholder')}
transition={{ delay: 0.5, duration: 0.5 }}
type="text"
value={endpoint}
/>
<motion.button
animate={{ opacity: 1, y: 0 }}
className={cx(
screen4Styles.signInButton,
selfhostLoginStatus === 'loading' && screen4Styles.loadingButton,
!endpoint.trim() && screen4Styles.disabledButton,
)}
disabled={
!endpoint.trim() || selfhostLoginStatus === 'loading' || isConnectingServer
}
initial={{ opacity: 0, y: 30 }}
key="selfhost-button"
onClick={handleSelfhostConnect}
transition={{ delay: 0.6, duration: 0.5 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{selfhostLoginStatus === 'loading'
? t('screen5.actions.connecting')
: t('screen5.actions.connectToServer')}
</motion.button>
</div>
</>
);
}
default: {
return null;
}
}
};
return (
<div className={layoutStyles.fullScreen}>
{/* 内容层 */}
<div className={layoutStyles.centered}>
{/* 标题部分 */}
<TitleSection
animated={true}
badge={t('screen5.badge')}
description={t('screen5.description')}
title={t('screen5.title')}
/>
{/* 登录区域 */}
<div className={screen4Styles.contentContainer}>
{/* 登录内容包装器 - 固定高度 */}
<div className={screen4Styles.loginContentWrapper}>{renderLoginContent()}</div>
{/* 登录方式选择 - 只在没有登录结果时显示 */}
{!shouldShowResult() && (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={screen4Styles.methodSelector}
initial={{ opacity: 0, y: 30 }}
transition={{ delay: 0.7, duration: 0.5 }}
>
<div className={screen4Styles.methodOptions}>
{Object.values(loginMethodMetas).map((method) => (
<motion.div
className={cx(
screen4Styles.methodCard,
currentMethod === method.id && 'active',
)}
key={method.id}
onClick={() => handleMethodChange(method.id)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{method.icon && <method.icon size={20} />}
<span className={screen4Styles.methodCardText}>{t(method.nameKey)}</span>
</motion.div>
))}
</div>
</motion.div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { createStaticStyles } from 'antd-style';
export const styles = createStaticStyles(({ css, cssVar }) => ({
// Divider 样式
divider: css`
height: 24px;
`,
// 内层容器 - 深色模式
innerContainerDark: css`
position: relative;
overflow: hidden;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgContainer};
`,
// 内层容器 - 浅色模式
innerContainerLight: css`
position: relative;
overflow: hidden;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgContainer};
`,
// 外层容器
outerContainer: css`
position: relative;
`,
}));
@@ -1,19 +0,0 @@
// 统一导出所有样式模块
import { layoutStyles } from './layout';
import { mediaStyles } from './media';
import { spacingStyles } from './spacing';
import { typographyStyles } from './typography';
export { layoutStyles } from './layout';
export { mediaStyles } from './media';
export { spacingStyles } from './spacing';
export { customTheme } from './theme';
export { typographyStyles } from './typography';
// 组合样式对象(用于需要多个样式模块的场景)
export const commonStyles = {
layout: layoutStyles,
media: mediaStyles,
spacing: spacingStyles,
typography: typographyStyles,
};
@@ -1,87 +0,0 @@
import { createStaticStyles } from 'antd-style';
// 布局相关的通用样式
export const layoutStyles = createStaticStyles(({ css }) => ({
// 绝对定位容器
absolute: css`
position: absolute;
inset-block: 0 0;
inset-inline: 0 0;
`,
// 居中容器 - 水平垂直居中的 Flex 容器
centered: css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
`,
// 内容布局
contentSection: css`
width: 100%;
max-width: 1152px;
min-height: 460px;
margin-block: 0;
margin-inline: auto;
padding: 24px;
text-align: center;
`,
// 固定底部容器
fixedBottom: css`
position: fixed;
inset-block-end: 0;
inset-inline: 0 0;
`,
// Flex 列布局
flexColumn: css`
display: flex;
flex-direction: column;
`,
// Flex 行布局
flexRow: css`
display: flex;
flex-direction: row;
align-items: center;
`,
// 全屏容器 - 用于需要占满整个视口的场景
fullScreen: css`
position: fixed;
z-index: 10;
inset-block: 0 0;
inset-inline: 0 0;
overflow: hidden;
width: 100vw;
height: 100vh;
`,
// 最大宽度容器
maxWidthContainer: css`
width: 100%;
max-width: 1024px;
margin-block: 0;
margin-inline: auto;
`,
// 相对定位容器
relative: css`
position: relative;
height: 100%;
`,
// 空间分布
spaceBetween: css`
display: flex;
align-items: center;
justify-content: space-between;
`,
}));
@@ -1,37 +0,0 @@
import { createStaticStyles } from 'antd-style';
// 媒体元素相关的通用样式
export const mediaStyles = createStaticStyles(({ css }) => ({
// 圆形图片
circleImage: css`
border-radius: 50%;
object-fit: cover;
`,
// 覆盖式图片 - 填充容器
coverImage: css`
width: 100%;
height: 100%;
object-fit: cover;
`,
// 图片容器
imageContainer: css`
position: relative;
overflow: hidden;
`,
// 响应式图片 - 保持宽高比
responsiveImage: css`
max-width: 100%;
height: auto;
object-fit: contain;
`,
// 响应式视频
responsiveVideo: css`
width: 100%;
height: 100%;
object-fit: cover;
`,
}));
@@ -1,55 +0,0 @@
import { createStaticStyles } from 'antd-style';
// 间距相关的通用样式
export const spacingStyles = createStaticStyles(({ cssVar, css }) => ({
// Gap (for flex/grid)
gap: css`
gap: ${cssVar.margin};
`,
gapLG: css`
gap: ${cssVar.marginLG};
`,
gapSM: css`
gap: ${cssVar.marginSM};
`,
// Margin
marginBottom: css`
margin-block-end: ${cssVar.margin};
`,
marginBottomLG: css`
margin-block-end: ${cssVar.marginLG};
`,
marginBottomSM: css`
margin-block-end: ${cssVar.marginSM};
`,
marginTop: css`
margin-block-start: ${cssVar.margin};
`,
marginTopLG: css`
margin-block-start: ${cssVar.marginLG};
`,
marginTopSM: css`
margin-block-start: ${cssVar.marginSM};
`,
// Padding
padding: css`
padding: ${cssVar.padding};
`,
paddingLG: css`
padding: ${cssVar.paddingLG};
`,
paddingSM: css`
padding: ${cssVar.paddingSM};
`,
}));
@@ -1,88 +0,0 @@
import { theme } from 'antd';
// 自定义主题配置
export const customTheme = {
// 使用暗黑主题算法
algorithm: theme.darkAlgorithm,
// 组件级别的主题配置
components: {
Button: {
colorTextLightSolid: '#000000',
// 移除 Primary 按钮阴影
defaultShadow: 'none',
primaryShadow: 'none', // 移除 Default 按钮阴影
// 如需更多自定义,可添加:
// colorPrimaryHover: '#颜色值', // 悬停时的颜色
// defaultBorderColor: '#颜色值', // Default 按钮边框色
},
// 其他组件配置示例:
// Input: {
// colorPrimary: '#FFDE04',
// },
},
token: {
// 基础样式
borderRadius: 8,
// 背景色
colorBgBase: '#000000',
// 暗黑模式背景
colorBgElevated: 'rgba(0, 0, 0, 0.85)',
// 绿色 - 用于成功状态
colorBlue: '#4A77FF',
// 导航条等浮层背景
colorBorderSecondary: 'rgba(255, 255, 255, 0.08)',
// 成功色
colorError: '#ff4d4f',
// 错误色
// 渐变背景色
colorGradientPrimary: '#732FAE',
// 主渐变色
colorGradientSecondary: '#3A31C1',
// 黄色 - 用于突出显示
colorGreen: '#67AF3F',
colorHighlight: '#FFDE04',
colorHighlightHover: '#FFE227',
// 主色 - 黄色(影响 Primary 按钮等主要元素)
colorPrimary: '#FFFFFF',
// 蓝色 - 用于信息提示
colorPurple: '#7A45D3',
// 紫色 - 用于特殊功能
colorSuccess: '#52c41a',
colorTextBase: '#fff',
colorTextSecondary: 'rgba(255,255,255,0.45)',
// 品牌色彩系统
colorYellow: '#FFCB47',
// 次渐变色
// 字体配置
fontFamily:
"'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
fontSize: 14,
fontSizeLG: 20, // 边框色
},
};
// 导出主题相关的工具函数
export const getThemeToken = () => {
// 这里可以根据需要返回动态的主题配置
return customTheme.token;
};
@@ -1,94 +0,0 @@
import { createStaticStyles } from 'antd-style';
import { getThemeToken } from './theme';
const colorToken = getThemeToken();
// 文字排版相关的通用样式
export const typographyStyles = createStaticStyles(({ css, cssVar }) => ({
badge: css`
display: inline-block;
padding-block: ${cssVar.paddingXS};
padding-inline: ${cssVar.paddingMD};
border-radius: 100px;
font-size: ${cssVar.fontSize};
font-weight: 500;
color: #000;
background-color: ${colorToken.colorHighlight};
`,
// 正文文本 - 用于主要内容
body: css`
max-width: 672px;
margin: 0;
padding-block: 0;
padding-inline: ${cssVar.paddingLG};
font-size: ${cssVar.fontSizeLG};
line-height: ${cssVar.lineHeight};
color: ${colorToken.colorTextSecondary};
text-align: center;
`,
// 加粗文本
bold: css`
font-weight: 600;
`,
// 描述文本 - 用于次要说明
description: css`
max-width: 576px;
margin: 0;
font-size: ${cssVar.fontSize};
line-height: ${cssVar.lineHeight};
color: ${colorToken.colorTextSecondary};
text-align: center;
`,
// 主标题 - 用于页面主要标题
heroTitle: css`
font-family: ${cssVar.fontFamily};
font-size: 80px;
font-weight: 900;
font-style: italic;
line-height: 0.9;
color: ${colorToken.colorTextBase};
text-align: center;
letter-spacing: -5px;
`,
// 小号文本
small: css`
font-size: ${cssVar.fontSizeSM};
color: ${colorToken.colorTextSecondary};
`,
// 副标题 - 用于次级标题
subtitle: css`
margin-block: ${cssVar.marginXS};
margin-inline: 0;
font-family: ${cssVar.fontFamily};
font-size: 48px;
font-weight: 700;
font-style: italic;
color: ${colorToken.colorTextBase};
text-align: center;
`,
// 文本对齐
textCenter: css`
text-align: center;
`,
textLeft: css`
text-align: start;
`,
textRight: css`
text-align: end;
`,
}));
+30 -20
View File
@@ -5,7 +5,9 @@ export default {
'Please click the Start button below to continue using LobeHub Desktop',
'authResult.success.title': 'Authorization Successful',
'back': 'Back',
'navigation.next': 'Continue',
'next': 'Next',
'screen1.description': 'AI-powered productivity platform with intelligent agents',
'screen1.navigation.next': 'Start Setting Up',
@@ -33,38 +35,43 @@ export default {
'screen3.actions.openSettings': 'Open Settings',
'screen3.badge': 'Permissions',
'screen3.description':
"Grant the following permissions to experience LobeHub's full capabilities",
'Grant permissions to unlock the full potential of Agents and Groups. You can manage these anytime in settings.',
'screen3.permissions.1.description':
'Send system notifications for task completions, AI responses, and important updates when the app is running in background',
'screen3.permissions.1.title': 'Notification Permission',
'Receive notifications when tasks complete, Agents respond, or important updates arrive',
'screen3.permissions.1.title': 'Notifications',
'screen3.permissions.2.description':
'Access documents and files to enable AI analysis, knowledge base creation, and document processing workflows',
'screen3.permissions.2.title': 'File & Folder Access',
'Access files and folders to enable document analysis, knowledge base creation, and file processing workflows',
'screen3.permissions.2.title': 'File Access',
'screen3.permissions.3.description':
'Capture screen content and audio input for voice interactions, screen analysis, and multimodal AI assistance',
'screen3.permissions.3.title': 'Screen & Audio Recording',
'Capture screen content and audio for voice interactions, screen analysis, and multimodal assistance',
'screen3.permissions.3.title': 'Screen & Audio',
'screen3.permissions.4.description':
'Enable system-level automation and enhanced integration for seamless AI workflow execution across applications',
'screen3.permissions.4.title': 'Accessibility Settings',
'screen3.title': 'Enable Full Experience',
'Enable system-level automation for seamless workflow execution across applications',
'screen3.permissions.4.title': 'Accessibility',
'screen3.title': 'Grant Permissions',
'screen3.title2': 'Enable access to unlock full features',
'screen3.title3': 'You can manage these anytime in settings',
'screen4.badge': 'Privacy',
'screen4.description': 'Choose how you want to use LobeHub.',
'screen4.footerNote': 'You can always change this later in the settings.',
'screen4.description':
'Choose how you want to share data. Your choice helps us improve, and you can change this anytime in settings.',
'screen4.footerNote': 'You can change this anytime in settings',
'screen4.navigation.next': 'Continue',
'screen4.privacy.description':
'If you enable Privacy Mode, none of your questions or conversations will ever be stored by us.',
'Keep everything local. No data is collected or shared—complete privacy for your conversations and workflows.',
'screen4.privacy.items.1': 'No data collection',
'screen4.privacy.items.2': 'No usage analytics',
'screen4.privacy.items.3': 'All processing stays local',
'screen4.privacy.title': 'Privacy Mode',
'screen4.share.description':
'To make LobeHub better, this option lets us collect usage data. This includes:',
'Share anonymized usage data to help us improve LobeHub. This helps us understand how Agents are used and make them better.',
'screen4.share.items.1': 'Performance metrics',
'screen4.share.items.2': 'Model usage patterns',
'screen4.share.items.3': 'Feature interactions',
'screen4.share.title': 'Help Improve LobeHub',
'screen4.title': 'Data Preferences',
'screen4.title': 'How would you like to share data?',
'screen4.title2': 'Your choice helps us improve',
'screen4.title3': 'You can change this anytime in settings',
'screen5.actions.connectToServer': 'Connect to Server',
'screen5.actions.connecting': 'Connecting...',
@@ -75,14 +82,17 @@ export default {
'screen5.actions.tryAgain': 'Try Again',
'screen5.badge': 'Sign in',
'screen5.description':
'Sign in to sync your AI agents, settings, and conversations across all devices.',
'Sign in to sync Agents, Groups, settings, and Context across all devices.',
'screen5.errors.desktopOnlyOidc':
'OIDC authorization is only available in the desktop app runtime.',
'screen5.methods.cloud.description': 'Authorization by Official cloud-based version',
'screen5.methods.cloud.description':
'Sign in with your LobeHub Cloud account to sync everything seamlessly',
'screen5.methods.cloud.name': 'LobeHub Cloud',
'screen5.methods.selfhost.description': 'Connect to your own LobeHub server instance',
'screen5.methods.selfhost.name': 'Self-hosted Instance',
'screen5.navigation.next': 'Start Using LobeHub',
'screen5.selfhost.endpointPlaceholder': 'Endpoint URL (Example: https://your-server.com)',
'screen5.title': 'Connect Your Account',
'screen5.navigation.next': 'Get Started',
'screen5.selfhost.endpointPlaceholder': 'Enter your server URL (e.g., https://your-server.com)',
'screen5.title': 'Sign in to sync across devices',
'screen5.title2': 'Keep your data synchronized everywhere',
'screen5.title3': 'Your data stays in your control',
};