style: update desktop onboarding
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
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;
|
||||
@@ -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;
|
||||
`,
|
||||
}));
|
||||
@@ -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',
|
||||
};
|
||||
|
||||