Compare commits
32 Commits
ce19881181
...
e277352133
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e277352133 | ||
|
|
87437ed229 | ||
|
|
037471d880 | ||
|
|
0c482bc72c | ||
|
|
4aebb3bf4b | ||
|
|
ed241bd9c5 | ||
|
|
e6ede81c78 | ||
|
|
a0b688da80 | ||
|
|
6bd09b797d | ||
|
|
9c532b5f18 | ||
|
|
1d1d6c8169 | ||
|
|
3507cfe9f7 | ||
|
|
cc520893f8 | ||
|
|
dabedc1c0b | ||
|
|
7b4c4be7bf | ||
|
|
7a2c73f3ca | ||
|
|
105a0b02ea | ||
|
|
d8a4c20565 | ||
|
|
5f959fb44f | ||
|
|
ee78e00d3b | ||
|
|
2fcc341213 | ||
|
|
1090a2fc67 | ||
|
|
77f3949fe2 | ||
|
|
742ab337dc | ||
|
|
d2b6904a4a | ||
|
|
789a6229a7 | ||
|
|
6886a649f5 | ||
|
|
581e874b0d | ||
|
|
b23ed93020 | ||
|
|
84f70f3329 | ||
|
|
601b06d79e | ||
|
|
0818a7bff7 |
57
app.py
57
app.py
@@ -3475,6 +3475,46 @@ def get_wechat_qrcode():
|
||||
}}), 200
|
||||
|
||||
|
||||
@app.route('/api/auth/wechat/h5-auth', methods=['POST'])
|
||||
def get_wechat_h5_auth_url():
|
||||
"""
|
||||
获取微信 H5 网页授权 URL
|
||||
用于手机浏览器跳转微信 App 授权
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
frontend_redirect = data.get('redirect_url', '/home')
|
||||
|
||||
# 生成唯一 state
|
||||
state = uuid.uuid4().hex
|
||||
|
||||
# 编码回调地址
|
||||
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
||||
|
||||
# 构建授权 URL(使用 snsapi_login 获取用户信息)
|
||||
auth_url = (
|
||||
f"https://open.weixin.qq.com/connect/oauth2/authorize?"
|
||||
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
|
||||
f"&response_type=code&scope=snsapi_login&state={state}"
|
||||
"#wechat_redirect"
|
||||
)
|
||||
|
||||
# 存储 session 信息
|
||||
wechat_qr_sessions[state] = {
|
||||
'status': 'waiting',
|
||||
'expires': time.time() + 300,
|
||||
'mode': 'h5', # 标记为 H5 模式
|
||||
'frontend_redirect': frontend_redirect,
|
||||
'user_info': None,
|
||||
'wechat_openid': None,
|
||||
'wechat_unionid': None
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'auth_url': auth_url,
|
||||
'state': state
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route('/api/account/wechat/qrcode', methods=['GET'])
|
||||
def get_wechat_bind_qrcode():
|
||||
"""发起微信绑定二维码,会话标记为绑定模式"""
|
||||
@@ -3714,14 +3754,23 @@ def wechat_callback():
|
||||
# 更新微信session状态,供前端轮询检测
|
||||
if state in wechat_qr_sessions:
|
||||
session_item = wechat_qr_sessions[state]
|
||||
# 仅处理登录/注册流程,不处理绑定流程
|
||||
if not session_item.get('mode'):
|
||||
# 更新状态和用户信息
|
||||
mode = session_item.get('mode')
|
||||
|
||||
# H5 模式:重定向到前端回调页面
|
||||
if mode == 'h5':
|
||||
frontend_redirect = session_item.get('frontend_redirect', '/home/wechat-callback')
|
||||
# 清理 session
|
||||
del wechat_qr_sessions[state]
|
||||
print(f"✅ H5 微信登录成功,重定向到: {frontend_redirect}")
|
||||
return redirect(f"{frontend_redirect}?wechat_login=success")
|
||||
|
||||
# PC 扫码模式:更新状态供前端轮询
|
||||
if not mode:
|
||||
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
|
||||
session_item['user_info'] = {'user_id': user.id}
|
||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||
|
||||
# 直接跳转到首页
|
||||
# PC 模式直接跳转到首页
|
||||
return redirect('/home')
|
||||
|
||||
except Exception as e:
|
||||
|
||||
145
src/App.js
145
src/App.js
@@ -27,17 +27,66 @@ import { PerformancePanel } from './components/PerformancePanel';
|
||||
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
|
||||
|
||||
// Redux
|
||||
import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import { initializePostHog } from './store/slices/posthogSlice';
|
||||
import { updateScreenSize } from './store/slices/deviceSlice';
|
||||
import { injectReducer } from './store';
|
||||
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入
|
||||
// import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
|
||||
let posthogModule = null;
|
||||
let posthogSliceModule = null;
|
||||
|
||||
/**
|
||||
* ⚡ 延迟加载 PostHog 模块
|
||||
* 返回 { trackEvent, trackEventAsync, initializePostHog, posthogReducer }
|
||||
*/
|
||||
const loadPostHogModules = async () => {
|
||||
if (posthogModule && posthogSliceModule) {
|
||||
return { posthogModule, posthogSliceModule };
|
||||
}
|
||||
|
||||
try {
|
||||
const [posthog, posthogSlice] = await Promise.all([
|
||||
import('@lib/posthog'),
|
||||
import('./store/slices/posthogSlice'),
|
||||
]);
|
||||
|
||||
posthogModule = posthog;
|
||||
posthogSliceModule = posthogSlice;
|
||||
|
||||
return { posthogModule, posthogSliceModule };
|
||||
} catch (error) {
|
||||
logger.error('App', 'PostHog 模块加载失败', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 异步追踪事件(延迟加载 PostHog 后调用)
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
const trackEventLazy = async (eventName, properties = {}) => {
|
||||
// 等待模块加载完成
|
||||
if (!posthogModule) {
|
||||
const modules = await loadPostHogModules();
|
||||
if (!modules) return;
|
||||
}
|
||||
|
||||
// 使用异步追踪,不阻塞主线程
|
||||
posthogModule.trackEventAsync(eventName, properties);
|
||||
};
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
@@ -51,28 +100,98 @@ function AppContent() {
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
// 🎯 ⚡ PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB)
|
||||
useEffect(() => {
|
||||
dispatch(initializePostHog());
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
const initPostHogRedux = async () => {
|
||||
try {
|
||||
const modules = await loadPostHogModules();
|
||||
if (!modules) return;
|
||||
|
||||
const { posthogSliceModule } = modules;
|
||||
|
||||
// 动态注入 PostHog reducer
|
||||
injectReducer('posthog', posthogSliceModule.default);
|
||||
|
||||
// 初始化 PostHog
|
||||
dispatch(posthogSliceModule.initializePostHog());
|
||||
|
||||
// ⚡ 刷新注入前缓存的事件(避免丢失)
|
||||
const pendingEvents = posthogSliceModule.flushPendingEventsBeforeInjection();
|
||||
if (pendingEvents.length > 0) {
|
||||
logger.info('App', `刷新 ${pendingEvents.length} 个注入前缓存的事件`);
|
||||
pendingEvents.forEach(({ eventName, properties }) => {
|
||||
posthogModule.trackEventAsync(eventName, properties);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('App', 'PostHog 模块空闲时加载完成,Redux 初始化已触发');
|
||||
} catch (error) {
|
||||
logger.error('App', 'PostHog 加载失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载,最长等待 3 秒
|
||||
if ('requestIdleCallback' in window) {
|
||||
const idleId = requestIdleCallback(initPostHogRedux, { timeout: 3000 });
|
||||
return () => cancelIdleCallback(idleId);
|
||||
} else {
|
||||
// 降级:Safari 等不支持 requestIdleCallback 的浏览器使用 setTimeout
|
||||
const timer = setTimeout(initPostHogRedux, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
// ⚡ 性能监控:标记 React 初始化完成
|
||||
useEffect(() => {
|
||||
performanceMonitor.mark('react-ready');
|
||||
}, []);
|
||||
|
||||
// 📱 设备检测:监听窗口尺寸变化
|
||||
useEffect(() => {
|
||||
let resizeTimer;
|
||||
const handleResize = () => {
|
||||
// 防抖:避免频繁触发
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
dispatch(updateScreenSize());
|
||||
}, 150);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimer);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
const eventData = {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// 🔴 关键事件:立即加载 PostHog 模块并同步追踪(不使用 trackEventLazy)
|
||||
// 确保首次访问数据不会因用户快速离开而丢失
|
||||
(async () => {
|
||||
const modules = await loadPostHogModules();
|
||||
if (modules) {
|
||||
// 使用同步追踪(trackEvent),而非异步追踪(trackEventAsync)
|
||||
modules.posthogModule.trackEvent('first_visit', eventData);
|
||||
logger.info('App', '首次访问事件已同步追踪', eventData);
|
||||
}
|
||||
})();
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
@@ -87,8 +206,8 @@ function AppContent() {
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
// ⚡ 使用延迟加载的异步追踪,不阻塞页面切换
|
||||
trackEventLazy('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
|
||||
@@ -216,12 +216,6 @@ export default function AuthFormContent() {
|
||||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
|
||||
dev_code: data.dev_code
|
||||
});
|
||||
|
||||
// ✅ 开发环境下在控制台显示验证码
|
||||
if (data.dev_code) {
|
||||
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
@@ -328,16 +322,6 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// ⚡ Mock 模式:先在前端侧写入 localStorage,确保时序正确
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true' && data.user) {
|
||||
setCurrentUser(data.user);
|
||||
logger.debug('AuthFormContent', '前端侧设置当前用户(Mock模式)', {
|
||||
userId: data.user?.id,
|
||||
phone: data.user?.phone,
|
||||
mockMode: true
|
||||
});
|
||||
}
|
||||
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
@@ -476,7 +460,8 @@ export default function AuthFormContent() {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [authEvents]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅在挂载时执行一次,避免 countdown 倒计时导致重复触发
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function WechatRegister() {
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -128,12 +129,8 @@ export default function WechatRegister() {
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(async (sessionId, status) => {
|
||||
try {
|
||||
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
|
||||
|
||||
const response = await authService.loginWithWechat(sessionId);
|
||||
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
@@ -182,40 +179,28 @@ export default function WechatRegister() {
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
logger.debug('WechatRegister', '检测到微信状态', {
|
||||
sessionId: wechatSessionId.substring(0, 8) + '...',
|
||||
status,
|
||||
userInfo: response.user_info
|
||||
});
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
// 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
|
||||
const previousStatus = wechatStatusRef.current;
|
||||
if (previousStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
@@ -227,7 +212,6 @@ export default function WechatRegister() {
|
||||
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
|
||||
clearTimers(); // 停止轮询
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
@@ -277,6 +261,12 @@ export default function WechatRegister() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权成功(AUTHORIZED)- 用户已在微信端确认授权,调用登录 API
|
||||
else if (status === WECHAT_STATUS.AUTHORIZED) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
@@ -301,11 +291,6 @@ export default function WechatRegister() {
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -316,7 +301,6 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
@@ -368,11 +352,6 @@ export default function WechatRegister() {
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
@@ -404,6 +383,14 @@ export default function WechatRegister() {
|
||||
}
|
||||
}, [getWechatQRCode]);
|
||||
|
||||
/**
|
||||
* 同步 wechatStatusRef 与 wechatStatus state
|
||||
* 确保 checkWechatStatus 回调中能获取到最新状态
|
||||
*/
|
||||
useEffect(() => {
|
||||
wechatStatusRef.current = wechatStatus;
|
||||
}, [wechatStatus]);
|
||||
|
||||
/**
|
||||
* 组件卸载时清理定时器和标记组件状态
|
||||
*/
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { svgs } from "./svgs";
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { svgs } from './svgs';
|
||||
|
||||
const Button = ({
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
children,
|
||||
px,
|
||||
white,
|
||||
interface ButtonProps {
|
||||
className?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
px?: string;
|
||||
white?: boolean;
|
||||
isPrimary?: boolean;
|
||||
isSecondary?: boolean;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
children,
|
||||
px,
|
||||
white,
|
||||
}) => {
|
||||
const classes = `button relative inline-flex items-center justify-center h-11 ${
|
||||
px || "px-7"
|
||||
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${
|
||||
className || ""
|
||||
}`;
|
||||
const classes = `button relative inline-flex items-center justify-center h-11 ${
|
||||
px || 'px-7'
|
||||
} ${white ? 'text-n-8' : 'text-n-1'} transition-colors hover:text-color-1 ${
|
||||
className || ''
|
||||
}`;
|
||||
|
||||
const spanClasses = `relative z-10`;
|
||||
const spanClasses = `relative z-10`;
|
||||
|
||||
return href ? (
|
||||
href.startsWith("mailto:") ? (
|
||||
<a href={href} className={classes}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} className={classes}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</Link>
|
||||
)
|
||||
return href ? (
|
||||
href.startsWith('mailto:') ? (
|
||||
<a href={href} className={classes}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</a>
|
||||
) : (
|
||||
<button className={classes} onClick={onClick}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</button>
|
||||
);
|
||||
<Link to={href} className={classes}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<button className={classes} onClick={onClick}>
|
||||
<span className={spanClasses}>{children}</span>
|
||||
{svgs(white)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from "react";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
|
||||
type CommonProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
isPrimary?: boolean;
|
||||
isSecondary?: boolean;
|
||||
};
|
||||
|
||||
type ButtonAsButton = {
|
||||
as?: "button";
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type ButtonAsAnchor = {
|
||||
as: "a";
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
type ButtonAsLink = {
|
||||
as: "link";
|
||||
} & LinkProps;
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
children,
|
||||
isPrimary,
|
||||
isSecondary,
|
||||
as = "button",
|
||||
...props
|
||||
}) => {
|
||||
const isLink = as === "link";
|
||||
const Component: React.ElementType = isLink ? Link : as;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
|
||||
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
|
||||
} ${
|
||||
isSecondary
|
||||
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
|
||||
: ""
|
||||
} ${className || ""}`}
|
||||
{...(isLink ? (props as LinkProps) : props)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -1,20 +1,25 @@
|
||||
import Image from "../Image";
|
||||
import React from 'react';
|
||||
import Image from '../Image';
|
||||
|
||||
const Generating = ({ className }) => (
|
||||
<div
|
||||
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
|
||||
className || ""
|
||||
} text-base`}
|
||||
>
|
||||
<Image
|
||||
className="w-5 h-5 mr-4"
|
||||
src="/images/loading.png"
|
||||
width={20}
|
||||
height={20}
|
||||
alt="Loading"
|
||||
/>
|
||||
AI is generating|
|
||||
</div>
|
||||
interface GeneratingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Generating: React.FC<GeneratingProps> = ({ className }) => (
|
||||
<div
|
||||
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
|
||||
className || ''
|
||||
} text-base`}
|
||||
>
|
||||
<Image
|
||||
className="w-5 h-5 mr-4"
|
||||
src="/images/loading.png"
|
||||
width={20}
|
||||
height={20}
|
||||
alt="Loading"
|
||||
/>
|
||||
AI is generating|
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Generating;
|
||||
|
||||
@@ -2,95 +2,48 @@
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectIsMobile } from '@/store/slices/deviceSlice';
|
||||
|
||||
// Global Components
|
||||
import AuthModalManager from './Auth/AuthModalManager';
|
||||
import NotificationContainer from './NotificationContainer';
|
||||
import ConnectionStatusBar from './ConnectionStatusBar';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以在这里包装
|
||||
*/
|
||||
function ConnectionStatusBarWrapper() {
|
||||
const { connectionStatus, reconnectAttempt, maxReconnectAttempts, retryConnection } = useNotification();
|
||||
const [isDismissed, setIsDismissed] = React.useState(false);
|
||||
|
||||
// 监听连接状态变化
|
||||
React.useEffect(() => {
|
||||
// 重连成功后,清除 dismissed 状态
|
||||
if (connectionStatus === 'connected' && isDismissed) {
|
||||
setIsDismissed(false);
|
||||
// 从 localStorage 清除 dismissed 标记
|
||||
localStorage.removeItem('connection_status_dismissed');
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复 dismissed 状态
|
||||
if (connectionStatus !== 'connected' && !isDismissed) {
|
||||
const dismissed = localStorage.getItem('connection_status_dismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}
|
||||
}, [connectionStatus, isDismissed]);
|
||||
|
||||
const handleClose = () => {
|
||||
// 用户手动关闭,保存到 localStorage
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem('connection_status_dismissed', 'true');
|
||||
logger.info('App', 'Connection status bar dismissed by user');
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionStatusBar
|
||||
status={connectionStatus}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
maxReconnectAttempts={maxReconnectAttempts}
|
||||
onRetry={retryConnection}
|
||||
onClose={handleClose}
|
||||
isDismissed={isDismissed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalComponents - 全局组件容器
|
||||
* 集中管理所有全局级别的组件,如弹窗、通知、状态栏等
|
||||
*
|
||||
* 包含的组件:
|
||||
* - ConnectionStatusBarWrapper: Socket 连接状态条
|
||||
* - ScrollToTop: 路由切换时自动滚动到顶部
|
||||
* - AuthModalManager: 认证弹窗管理器
|
||||
* - NotificationContainer: 通知容器
|
||||
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
|
||||
* - NotificationContainer: 通知容器(仅桌面端渲染)
|
||||
* - BytedeskWidget: Bytedesk在线客服
|
||||
*
|
||||
* 注意:
|
||||
* - ConnectionStatusBar 已移除(所有端)
|
||||
* - NotificationContainer 在移动端不渲染(通知功能已在 NotificationContext 层禁用)
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
<ConnectionStatusBarWrapper />
|
||||
|
||||
{/* 路由切换时自动滚动到顶部 */}
|
||||
<ScrollToTop />
|
||||
|
||||
{/* 认证弹窗管理器 */}
|
||||
<AuthModalManager />
|
||||
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
{/* 通知容器(仅桌面端渲染) */}
|
||||
{!isMobile && <NotificationContainer />}
|
||||
|
||||
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||
<BytedeskWidget
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Image = ({ className, ...props }) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={`inline-block align-top opacity-0 transition-opacity ${
|
||||
loaded && "opacity-100"
|
||||
} ${className}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const Image: React.FC<ImageProps> = ({ className, ...props }) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<img
|
||||
className={`inline-block align-top opacity-0 transition-opacity ${
|
||||
loaded && 'opacity-100'
|
||||
} ${className || ''}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Image;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
type JoinProps = {};
|
||||
|
||||
const Join = ({}: JoinProps) => (
|
||||
<Section crosses>
|
||||
<div className="container">
|
||||
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
|
||||
<div className="relative z-1 text-center">
|
||||
<h1 className="h1 mb-6">
|
||||
Be part of the future of{" "}
|
||||
<span className="inline-block relative">
|
||||
Brainwave
|
||||
<Image
|
||||
className="absolute top-full left-0 w-full"
|
||||
src="/images/curve.png"
|
||||
width={624}
|
||||
height={28}
|
||||
alt="Curve"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="body-1 mb-8 text-n-4">
|
||||
Unleash the power of AI within Brainwave. Upgrade your
|
||||
productivity with Brainwave, the open AI chat app.
|
||||
</p>
|
||||
<Button href="/pricing" white>
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/join/shapes-1.svg"
|
||||
width={317}
|
||||
height={293}
|
||||
alt="Shapes 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/join/shapes-2.svg"
|
||||
width={451}
|
||||
height={266}
|
||||
alt="Shapes 2"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Join;
|
||||
@@ -1,73 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
type JoinProps = {};
|
||||
|
||||
const Join = ({}: JoinProps) => (
|
||||
<Section crosses>
|
||||
<div className="container">
|
||||
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
|
||||
<div className="relative z-1 text-center">
|
||||
<h1 className="h1 mb-6">
|
||||
Be part of the future of{" "}
|
||||
<span className="inline-block relative">
|
||||
Brainwave
|
||||
<Image
|
||||
className="absolute top-full left-0 w-full"
|
||||
src="/images/curve.png"
|
||||
width={624}
|
||||
height={28}
|
||||
alt="Curve"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="body-1 mb-8 text-n-4">
|
||||
Unleash the power of AI within Brainwave. Upgrade your
|
||||
productivity with Brainwave, the open AI chat app.
|
||||
</p>
|
||||
<Button href="/pricing" white>
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/join/shapes-1.svg"
|
||||
width={317}
|
||||
height={293}
|
||||
alt="Shapes 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/join/shapes-2.svg"
|
||||
width={451}
|
||||
height={266}
|
||||
alt="Shapes 2"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Join;
|
||||
@@ -1,53 +0,0 @@
|
||||
import Image from "../Image";
|
||||
|
||||
const Logos = ({ className }) => (
|
||||
<div className={className}>
|
||||
<h5 className="tagline mb-6 text-center text-n-1/50">
|
||||
Helping people create beautiful content at
|
||||
</h5>
|
||||
<ul className="flex">
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logos;
|
||||
@@ -1,53 +0,0 @@
|
||||
import Image from "../Image";
|
||||
|
||||
const Logos = ({ className }) => (
|
||||
<div className={className}>
|
||||
<h5 className="tagline mb-6 text-center text-n-1/50">
|
||||
Helping people create beautiful content at
|
||||
</h5>
|
||||
<ul className="flex">
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
||||
<Image
|
||||
src="/images/yourlogo.svg"
|
||||
width={134}
|
||||
height={28}
|
||||
alt="Logo 3"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Logos;
|
||||
@@ -2,7 +2,7 @@
|
||||
// Navbar 右侧功能区组件
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react';
|
||||
import { HStack, IconButton, Box } from '@chakra-ui/react';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
|
||||
import LoginButton from '../LoginButton';
|
||||
@@ -41,9 +41,15 @@ const NavbarActions = memo(({
|
||||
}) => {
|
||||
return (
|
||||
<HStack spacing={{ base: 2, md: 4 }}>
|
||||
{/* 显示加载状态 */}
|
||||
{/* 权限校验中 - 显示占位骨架,不显示登录按钮或用户菜单 */}
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Box
|
||||
w={{ base: '80px', md: '120px' }}
|
||||
h="36px"
|
||||
borderRadius="md"
|
||||
bg="whiteAlpha.100"
|
||||
opacity={0.6}
|
||||
/>
|
||||
) : isAuthenticated && user ? (
|
||||
// 已登录状态 - 用户菜单 + 功能菜单排列
|
||||
<HStack spacing={{ base: 2, md: 3 }}>
|
||||
|
||||
@@ -57,13 +57,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||
onClick={highFreqMenu.handleClick}
|
||||
@@ -123,13 +124,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'inherit'}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
|
||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||
onClick={marketReviewMenu.handleClick}
|
||||
@@ -198,13 +200,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import Image from "../Image";
|
||||
|
||||
const Notification = ({ className, title }) => (
|
||||
<div
|
||||
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="mr-5">
|
||||
<Image
|
||||
className="w-full rounded-xl"
|
||||
src="/images/notification/image-1.png"
|
||||
width={52}
|
||||
height={52}
|
||||
alt="Image"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h6 className="mb-1 font-semibold text-base">{title}</h6>
|
||||
<div className="flex items-center justify-between">
|
||||
<ul className="flex -m-0.5">
|
||||
{[
|
||||
"/images/notification/image-4.png",
|
||||
"/images/notification/image-3.png",
|
||||
"/images/notification/image-2.png",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
|
||||
index !== 0 ? "-ml-2" : ""
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
className="w-full"
|
||||
src={item}
|
||||
width={20}
|
||||
height={20}
|
||||
alt={item}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="body-2 text-[#6C7275]">1m ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Notification;
|
||||
@@ -1,49 +0,0 @@
|
||||
import Image from "../Image";
|
||||
|
||||
const Notification = ({ className, title }) => (
|
||||
<div
|
||||
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="mr-5">
|
||||
<Image
|
||||
className="w-full rounded-xl"
|
||||
src="/images/notification/image-1.png"
|
||||
width={52}
|
||||
height={52}
|
||||
alt="Image"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h6 className="mb-1 font-semibold text-base">{title}</h6>
|
||||
<div className="flex items-center justify-between">
|
||||
<ul className="flex -m-0.5">
|
||||
{[
|
||||
"/images/notification/image-4.png",
|
||||
"/images/notification/image-3.png",
|
||||
"/images/notification/image-2.png",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
|
||||
index !== 0 ? "-ml-2" : ""
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
className="w-full"
|
||||
src={item}
|
||||
width={20}
|
||||
height={20}
|
||||
alt={item}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="body-2 text-[#6C7275]">1m ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Notification;
|
||||
@@ -1,57 +1,67 @@
|
||||
const Section = ({
|
||||
className,
|
||||
crosses,
|
||||
crossesOffset,
|
||||
customPaddings,
|
||||
children,
|
||||
import React from 'react';
|
||||
|
||||
interface SectionProps {
|
||||
className?: string;
|
||||
crosses?: boolean;
|
||||
crossesOffset?: string;
|
||||
customPaddings?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
className,
|
||||
crosses,
|
||||
crossesOffset,
|
||||
customPaddings,
|
||||
children,
|
||||
}) => (
|
||||
<div
|
||||
className={`relative ${
|
||||
customPaddings ||
|
||||
`py-10 lg:py-16 xl:py-20 ${crosses ? "lg:py-32 xl:py-40" : ""}`
|
||||
} ${className || ""}`}
|
||||
>
|
||||
{children}
|
||||
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
|
||||
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
|
||||
{crosses && (
|
||||
<>
|
||||
<div
|
||||
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:left-10 right-10`}
|
||||
></div>
|
||||
<svg
|
||||
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:left-[2.1875rem]`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:right-[2.1875rem]`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`relative ${
|
||||
customPaddings ||
|
||||
`py-10 lg:py-16 xl:py-20 ${crosses ? 'lg:py-32 xl:py-40' : ''}`
|
||||
} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
|
||||
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
|
||||
{crosses && (
|
||||
<>
|
||||
<div
|
||||
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:left-10 right-10`}
|
||||
></div>
|
||||
<svg
|
||||
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:left-[2.1875rem]`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
|
||||
crossesOffset && crossesOffset
|
||||
} pointer-events-none lg:block xl:right-[2.1875rem]`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Section;
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Generating from "@/components/Generating";
|
||||
import Image from "@/components/Image";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type ServicesProps = {
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
const Services = ({ containerClassName }: ServicesProps) => (
|
||||
<Section>
|
||||
<div className={`container ${containerClassName || ""}`}>
|
||||
<Heading
|
||||
title="Generative AI made for creators."
|
||||
text="Brainwave unlocks the potential of AI-powered applications"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
|
||||
<Image
|
||||
className="w-full h-full object-cover md:object-right"
|
||||
src="/images/services/service-1.png"
|
||||
width={797}
|
||||
height={733}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-1 max-w-[17rem] ml-auto">
|
||||
<h4 className="h4 mb-4">Smartest AI</h4>
|
||||
<p className="bpdy-2 mb-[3.125rem] text-n-3">
|
||||
Brainwave unlocks the potential of AI-powered
|
||||
applications
|
||||
</p>
|
||||
<ul className="body-2">
|
||||
{[
|
||||
"Photo generating",
|
||||
"Photo enhance",
|
||||
"Seamless Integration",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className="flex items-start py-4 border-t border-n-6"
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<p className="ml-4">{item}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
|
||||
</div>
|
||||
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
|
||||
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/services/service-2.png"
|
||||
width={630}
|
||||
height={748}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
|
||||
<h4 className="h4 mb-4">Photo editing</h4>
|
||||
<p className="body-2 text-n-3">
|
||||
{`Automatically enhance your photos using our AI app's
|
||||
photo editing feature. Try it now!`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
|
||||
Hey Brainwave, enhance this photo
|
||||
<svg
|
||||
className="absolute left-full bottom-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="37"
|
||||
>
|
||||
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
|
||||
<div className="py-12 px-4 xl:px-8">
|
||||
<h4 className="h4 mb-4">Video generation</h4>
|
||||
<p className="body-2 mb-[2.25rem] text-n-3">
|
||||
The world’s most powerful AI photo and video art
|
||||
generation engine.What will you create?
|
||||
</p>
|
||||
<ul className="flex items-center justify-between">
|
||||
{[
|
||||
"/images/icons/recording-03.svg",
|
||||
"/images/icons/recording-01.svg",
|
||||
"/images/icons/disc-02.svg",
|
||||
"/images/icons/chrome-cast.svg",
|
||||
"/images/icons/sliders-04.svg",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className={`flex items-center justify-center ${
|
||||
index === 2
|
||||
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
|
||||
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
index === 2
|
||||
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={item}
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/services/service-3.png"
|
||||
width={517}
|
||||
height={400}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
|
||||
Video generated!
|
||||
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
|
||||
<Image
|
||||
src="/images/brainwave-symbol-white.svg"
|
||||
width={26}
|
||||
height={26}
|
||||
alt="Brainwave"
|
||||
/>
|
||||
</div>
|
||||
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
|
||||
just now
|
||||
</div>
|
||||
<svg
|
||||
className="absolute right-full bottom-0 -scale-x-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="37"
|
||||
>
|
||||
<path
|
||||
className="fill-n-6"
|
||||
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
|
||||
<svg
|
||||
className="mr-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 bg-[#D9D9D9]">
|
||||
<div className="w-1/2 h-0.5 bg-color-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
|
||||
<Image
|
||||
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
|
||||
src="/images/gradient.png"
|
||||
width={1417}
|
||||
height={1417}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Services;
|
||||
@@ -1,195 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Generating from "@/components/Generating";
|
||||
import Image from "@/components/Image";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type ServicesProps = {
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
const Services = ({ containerClassName }: ServicesProps) => (
|
||||
<Section>
|
||||
<div className={`container ${containerClassName || ""}`}>
|
||||
<Heading
|
||||
title="Generative AI made for creators."
|
||||
text="Brainwave unlocks the potential of AI-powered applications"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
|
||||
<Image
|
||||
className="w-full h-full object-cover md:object-right"
|
||||
src="/images/services/service-1.png"
|
||||
width={797}
|
||||
height={733}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-1 max-w-[17rem] ml-auto">
|
||||
<h4 className="h4 mb-4">Smartest AI</h4>
|
||||
<p className="bpdy-2 mb-[3.125rem] text-n-3">
|
||||
Brainwave unlocks the potential of AI-powered
|
||||
applications
|
||||
</p>
|
||||
<ul className="body-2">
|
||||
{[
|
||||
"Photo generating",
|
||||
"Photo enhance",
|
||||
"Seamless Integration",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className="flex items-start py-4 border-t border-n-6"
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<p className="ml-4">{item}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
|
||||
</div>
|
||||
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
|
||||
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/services/service-2.png"
|
||||
width={630}
|
||||
height={748}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
|
||||
<h4 className="h4 mb-4">Photo editing</h4>
|
||||
<p className="body-2 text-n-3">
|
||||
{`Automatically enhance your photos using our AI app's
|
||||
photo editing feature. Try it now!`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
|
||||
Hey Brainwave, enhance this photo
|
||||
<svg
|
||||
className="absolute left-full bottom-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="37"
|
||||
>
|
||||
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
|
||||
<div className="py-12 px-4 xl:px-8">
|
||||
<h4 className="h4 mb-4">Video generation</h4>
|
||||
<p className="body-2 mb-[2.25rem] text-n-3">
|
||||
The world’s most powerful AI photo and video art
|
||||
generation engine.What will you create?
|
||||
</p>
|
||||
<ul className="flex items-center justify-between">
|
||||
{[
|
||||
"/images/icons/recording-03.svg",
|
||||
"/images/icons/recording-01.svg",
|
||||
"/images/icons/disc-02.svg",
|
||||
"/images/icons/chrome-cast.svg",
|
||||
"/images/icons/sliders-04.svg",
|
||||
].map((item, index) => (
|
||||
<li
|
||||
className={`flex items-center justify-center ${
|
||||
index === 2
|
||||
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
|
||||
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
index === 2
|
||||
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={item}
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/services/service-3.png"
|
||||
width={517}
|
||||
height={400}
|
||||
alt="Smartest AI"
|
||||
/>
|
||||
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
|
||||
Video generated!
|
||||
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
|
||||
<Image
|
||||
src="/images/brainwave-symbol-white.svg"
|
||||
width={26}
|
||||
height={26}
|
||||
alt="Brainwave"
|
||||
/>
|
||||
</div>
|
||||
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
|
||||
just now
|
||||
</div>
|
||||
<svg
|
||||
className="absolute right-full bottom-0 -scale-x-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="26"
|
||||
height="37"
|
||||
>
|
||||
<path
|
||||
className="fill-n-6"
|
||||
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
|
||||
<svg
|
||||
className="mr-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 bg-[#D9D9D9]">
|
||||
<div className="w-1/2 h-0.5 bg-color-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
|
||||
<Image
|
||||
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
|
||||
src="/images/gradient.png"
|
||||
width={1417}
|
||||
height={1417}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Services;
|
||||
@@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as echarts from 'echarts';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
@@ -72,11 +71,6 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
'daily',
|
||||
@@ -91,12 +85,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
|
||||
console.log('[KLineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
logger.error('KLineChartModal', 'loadData', err as Error);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -33,9 +33,6 @@ import {
|
||||
// 工具函数
|
||||
import { createSubIndicators } from './utils';
|
||||
|
||||
// 日志
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// ==================== 组件 Props ====================
|
||||
|
||||
export interface StockChartKLineModalProps {
|
||||
@@ -110,10 +107,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
|
||||
const newType = e.target.value as ChartType;
|
||||
setChartType(newType);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
|
||||
newType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -130,10 +123,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
// 先移除所有副图指标(KLineChart 会自动移除)
|
||||
// 然后创建新的指标
|
||||
createSubIndicators(chart, values);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
|
||||
indicators: values,
|
||||
});
|
||||
},
|
||||
[chart]
|
||||
);
|
||||
@@ -143,7 +132,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
*/
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadData();
|
||||
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
|
||||
}, [loadData]);
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
@@ -76,11 +75,6 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
'timeline',
|
||||
@@ -95,12 +89,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
|
||||
console.log('[TimelineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
logger.error('TimelineChartModal', 'loadData', err as Error);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createEventHighlightOverlay,
|
||||
removeAllEventMarkers,
|
||||
} from '../utils/eventMarkerUtils';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
export interface UseEventMarkerOptions {
|
||||
/** KLineChart 实例 */
|
||||
@@ -77,10 +76,6 @@ export const useEventMarker = (
|
||||
const createMarker = useCallback(
|
||||
(time: string, label: string, color?: string) => {
|
||||
if (!chart || !data || data.length === 0) {
|
||||
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
|
||||
hasChart: !!chart,
|
||||
dataLength: data?.length || 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,9 +88,6 @@ export const useEventMarker = (
|
||||
const overlay = createEventMarkerOverlay(eventMarker, data);
|
||||
|
||||
if (!overlay) {
|
||||
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
|
||||
eventMarker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,9 +95,6 @@ export const useEventMarker = (
|
||||
const id = chart.createOverlay(overlay);
|
||||
|
||||
if (!id || (Array.isArray(id) && id.length === 0)) {
|
||||
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
|
||||
overlay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,23 +107,9 @@ export const useEventMarker = (
|
||||
const highlightResult = chart.createOverlay(highlightOverlay);
|
||||
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
|
||||
setHighlightId(actualHighlightId as string);
|
||||
|
||||
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
|
||||
highlightId: actualHighlightId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
|
||||
markerId: actualId,
|
||||
label,
|
||||
time,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'createMarker', err as Error, {
|
||||
time,
|
||||
label,
|
||||
});
|
||||
// 忽略创建标记时的错误
|
||||
}
|
||||
},
|
||||
[chart, data]
|
||||
@@ -150,26 +125,17 @@ export const useEventMarker = (
|
||||
|
||||
try {
|
||||
if (markerId) {
|
||||
chart.removeOverlay(markerId);
|
||||
chart.removeOverlay({ id: markerId });
|
||||
}
|
||||
if (highlightId) {
|
||||
chart.removeOverlay(highlightId);
|
||||
chart.removeOverlay({ id: highlightId });
|
||||
}
|
||||
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
|
||||
markerId,
|
||||
highlightId,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeMarker', err as Error, {
|
||||
markerId,
|
||||
highlightId,
|
||||
});
|
||||
// 忽略移除标记时的错误
|
||||
}
|
||||
}, [chart, markerId, highlightId]);
|
||||
|
||||
@@ -186,12 +152,8 @@ export const useEventMarker = (
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeAllMarkers', err as Error);
|
||||
// 忽略移除所有标记时的错误
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
@@ -216,10 +178,10 @@ export const useEventMarker = (
|
||||
if (chart) {
|
||||
try {
|
||||
if (markerId) {
|
||||
chart.removeOverlay(markerId);
|
||||
chart.removeOverlay({ id: markerId });
|
||||
}
|
||||
if (highlightId) {
|
||||
chart.removeOverlay(highlightId);
|
||||
chart.removeOverlay({ id: highlightId });
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略清理时的错误
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Chart } from 'klinecharts';
|
||||
// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持
|
||||
import { getTheme, getTimelineTheme } from '../config/klineTheme';
|
||||
import { CHART_INIT_OPTIONS } from '../config';
|
||||
import { logger } from '@utils/logger';
|
||||
import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
|
||||
|
||||
export interface UseKLineChartOptions {
|
||||
@@ -65,11 +64,9 @@ export const useKLineChart = (
|
||||
// 全局注册自定义均价线指标(只执行一次)
|
||||
useEffect(() => {
|
||||
try {
|
||||
registerIndicator(avgPriceIndicator);
|
||||
logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
|
||||
registerIndicator(avgPriceIndicator as any);
|
||||
} catch (err) {
|
||||
// 如果已注册会报错,忽略即可
|
||||
logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -78,16 +75,10 @@ export const useKLineChart = (
|
||||
// 图表初始化函数
|
||||
const initChart = (): boolean => {
|
||||
if (!chartRef.current) {
|
||||
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('useKLineChart', 'init', '开始初始化图表', {
|
||||
containerId,
|
||||
height,
|
||||
colorMode,
|
||||
});
|
||||
|
||||
// 初始化图表实例(KLineChart 10.0 API)
|
||||
// ✅ 根据 chartType 选择主题
|
||||
@@ -112,29 +103,16 @@ export const useKLineChart = (
|
||||
|
||||
// ✅ 新增:创建成交量指标窗格
|
||||
try {
|
||||
const volumePaneId = chartInstance.createIndicator('VOL', false, {
|
||||
chartInstance.createIndicator('VOL', false, {
|
||||
height: 100, // 固定高度 100px(约占整体的 20-25%)
|
||||
});
|
||||
|
||||
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
|
||||
volumePaneId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
|
||||
error: err,
|
||||
});
|
||||
// 不阻塞主流程,继续执行
|
||||
}
|
||||
|
||||
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
|
||||
containerId,
|
||||
chartId: chartInstance.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineChart', 'init', error, { containerId });
|
||||
setError(error);
|
||||
setIsInitialized(false);
|
||||
return false;
|
||||
@@ -146,11 +124,6 @@ export const useKLineChart = (
|
||||
// 成功,直接返回清理函数
|
||||
return () => {
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
|
||||
dispose(chartInstanceRef.current);
|
||||
chartInstanceRef.current = null;
|
||||
setChartInstance(null); // ✅ 新增:清空 state
|
||||
@@ -161,7 +134,6 @@ export const useKLineChart = (
|
||||
|
||||
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
|
||||
initChart();
|
||||
}, 50);
|
||||
|
||||
@@ -169,11 +141,6 @@ export const useKLineChart = (
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
|
||||
dispose(chartInstanceRef.current);
|
||||
chartInstanceRef.current = null;
|
||||
setChartInstance(null); // ✅ 新增:清空 state
|
||||
@@ -195,14 +162,8 @@ export const useKLineChart = (
|
||||
? getTimelineTheme(colorMode)
|
||||
: getTheme(colorMode);
|
||||
chartInstanceRef.current.setStyles(newTheme);
|
||||
|
||||
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
|
||||
colorMode,
|
||||
chartType,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType });
|
||||
// 忽略主题更新错误
|
||||
}
|
||||
}, [colorMode, chartType, isInitialized]);
|
||||
|
||||
@@ -215,7 +176,6 @@ export const useKLineChart = (
|
||||
const handleResize = () => {
|
||||
if (chartInstanceRef.current) {
|
||||
chartInstanceRef.current.resize();
|
||||
logger.debug('useKLineChart', 'resize', '调整图表大小');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
|
||||
import { processChartData } from '../utils/dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||
|
||||
@@ -78,7 +77,6 @@ export const useKLineData = (
|
||||
*/
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) {
|
||||
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,11 +84,6 @@ export const useKLineData = (
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useKLineData', 'loadData', '开始加载数据', {
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
// 1. 先检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
|
||||
@@ -125,19 +118,8 @@ export const useKLineData = (
|
||||
const processedData = processChartData(rawDataList, chartType, eventTime);
|
||||
|
||||
setData(processedData);
|
||||
|
||||
logger.info('useKLineData', 'loadData', '数据加载成功', {
|
||||
stockCode,
|
||||
chartType,
|
||||
rawCount: rawDataList.length,
|
||||
processedCount: processedData.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineData', 'loadData', error, {
|
||||
stockCode,
|
||||
chartType,
|
||||
});
|
||||
setError(error);
|
||||
setData([]);
|
||||
setRawData([]);
|
||||
@@ -207,9 +189,7 @@ export const useKLineData = (
|
||||
(chart as any).setOffsetRightDistance(50);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useKLineData', 'updateChartData', err as Error, {
|
||||
step: '调整可见范围失败',
|
||||
});
|
||||
// 忽略调整可见范围时的错误
|
||||
}
|
||||
}, 100); // 延迟 100ms 确保数据已加载和渲染
|
||||
|
||||
@@ -259,14 +239,8 @@ export const useKLineData = (
|
||||
}, 200); // 延迟 200ms,确保均价线创建完成后再添加
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'useKLineData',
|
||||
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('useKLineData', 'updateChartData', err as Error, {
|
||||
dataCount: klineData.length,
|
||||
});
|
||||
// 忽略更新图表数据时的错误
|
||||
}
|
||||
},
|
||||
[chart, stockCode, chartType]
|
||||
@@ -279,11 +253,6 @@ export const useKLineData = (
|
||||
(newData: KLineDataPoint[]) => {
|
||||
setData(newData);
|
||||
updateChartData(newData);
|
||||
|
||||
logger.debug(
|
||||
'useKLineData',
|
||||
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
|
||||
);
|
||||
},
|
||||
[updateChartData]
|
||||
);
|
||||
@@ -298,7 +267,6 @@ export const useKLineData = (
|
||||
|
||||
if (chart) {
|
||||
chart.resetData();
|
||||
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* 计算公式:累计成交额 / 累计成交量
|
||||
*/
|
||||
|
||||
import type { Indicator, KLineData } from 'klinecharts';
|
||||
import type { KLineData } from 'klinecharts';
|
||||
|
||||
export const avgPriceIndicator: Indicator = {
|
||||
// 使用部分类型定义,因为 Indicator 类型很复杂
|
||||
export const avgPriceIndicator = {
|
||||
name: 'AVG',
|
||||
shortName: 'AVG',
|
||||
calcParams: [],
|
||||
calcParams: [] as number[],
|
||||
shouldOhlc: false, // 不显示 OHLC 信息
|
||||
shouldFormatBigNumber: false,
|
||||
precision: 2,
|
||||
minValue: null,
|
||||
maxValue: null,
|
||||
minValue: null as number | null,
|
||||
maxValue: null as number | null,
|
||||
|
||||
figures: [
|
||||
{
|
||||
@@ -61,33 +62,27 @@ export const avgPriceIndicator: Indicator = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Tooltip 格式化(显示均价 + 涨跌幅)
|
||||
* 自定义 Tooltip 数据源
|
||||
* 符合 IndicatorTooltipData 接口要求
|
||||
*/
|
||||
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
|
||||
if (!indicator?.avg) {
|
||||
return {
|
||||
title: { text: '均价', color: defaultStyles.tooltip.text.color },
|
||||
value: { text: '--', color: '#FF9800' },
|
||||
};
|
||||
}
|
||||
|
||||
const avgPrice = indicator.avg;
|
||||
const prevClose = kLineData?.prev_close;
|
||||
|
||||
// 计算均价涨跌幅
|
||||
let changeText = `¥${avgPrice.toFixed(2)}`;
|
||||
if (prevClose && prevClose > 0) {
|
||||
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
|
||||
const changeValue = (avgPrice - prevClose).toFixed(2);
|
||||
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
|
||||
}
|
||||
const avgValue = kLineData?.avg;
|
||||
const lineColor = defaultStyles?.lines?.[0]?.color || '#FF9800';
|
||||
|
||||
return {
|
||||
title: { text: '均价', color: defaultStyles.tooltip.text.color },
|
||||
value: {
|
||||
text: changeText,
|
||||
color: '#FF9800',
|
||||
},
|
||||
name: 'AVG',
|
||||
calcParamsText: '',
|
||||
features: [] as any[],
|
||||
legends: [
|
||||
{
|
||||
title: { text: '均价: ', color: lineColor },
|
||||
value: {
|
||||
text: avgValue !== undefined ? avgValue.toFixed(2) : '--',
|
||||
color: lineColor,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* 包含图表初始化、技术指标管理等通用逻辑
|
||||
*/
|
||||
|
||||
import type { Chart } from 'klinecharts';
|
||||
import { logger } from '@utils/logger';
|
||||
import type { Chart, ActionType } from 'klinecharts';
|
||||
|
||||
/**
|
||||
* 安全地执行图表操作(捕获异常)
|
||||
@@ -21,7 +20,6 @@ export const safeChartOperation = <T>(
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
logger.error('chartUtils', operation, error as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -50,13 +48,6 @@ export const createIndicator = (
|
||||
isStack
|
||||
);
|
||||
|
||||
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
|
||||
indicatorName,
|
||||
params,
|
||||
isStack,
|
||||
indicatorId,
|
||||
});
|
||||
|
||||
return indicatorId;
|
||||
});
|
||||
};
|
||||
@@ -69,8 +60,11 @@ export const createIndicator = (
|
||||
*/
|
||||
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
|
||||
safeChartOperation('removeIndicator', () => {
|
||||
chart.removeIndicator(indicatorId);
|
||||
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
|
||||
if (indicatorId) {
|
||||
chart.removeIndicator({ id: indicatorId });
|
||||
} else {
|
||||
chart.removeIndicator({});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,11 +88,6 @@ export const createSubIndicators = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
|
||||
indicators,
|
||||
createdIds: ids,
|
||||
});
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
@@ -130,10 +119,6 @@ export const setChartZoom = (chart: Chart, zoom: number): void => {
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
|
||||
zoom,
|
||||
newBarSpace,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -147,8 +132,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
safeChartOperation('scrollToTimestamp', () => {
|
||||
// KLineChart 10.0: 使用 scrollToTimestamp 方法
|
||||
chart.scrollToTimestamp(timestamp);
|
||||
|
||||
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -160,7 +143,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
export const resizeChart = (chart: Chart): void => {
|
||||
safeChartOperation('resizeChart', () => {
|
||||
chart.resize();
|
||||
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -194,7 +176,6 @@ export const getVisibleRange = (chart: Chart): { from: number; to: number } | nu
|
||||
export const clearChartData = (chart: Chart): void => {
|
||||
safeChartOperation('clearChartData', () => {
|
||||
chart.resetData();
|
||||
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -213,11 +194,6 @@ export const exportChartImage = (
|
||||
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
|
||||
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
|
||||
|
||||
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
|
||||
includeOverlay,
|
||||
hasData: !!imageData,
|
||||
});
|
||||
|
||||
return imageData;
|
||||
});
|
||||
};
|
||||
@@ -235,8 +211,6 @@ export const toggleCrosshair = (chart: Chart, show: boolean): void => {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -253,8 +227,6 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -267,12 +239,11 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
|
||||
*/
|
||||
export const subscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
eventName: ActionType,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
|
||||
chart.subscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -285,11 +256,10 @@ export const subscribeChartEvent = (
|
||||
*/
|
||||
export const unsubscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
eventName: ActionType,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
|
||||
chart.unsubscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 将后端原始数据转换为 KLineChart 标准格式
|
||||
@@ -22,7 +21,6 @@ export const convertToKLineData = (
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
|
||||
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -37,15 +35,11 @@ export const convertToKLineData = (
|
||||
low: Number(item.low) || 0,
|
||||
close: Number(item.close) || 0,
|
||||
volume: Number(item.volume) || 0,
|
||||
turnover: item.turnover ? Number(item.turnover) : undefined,
|
||||
turnover: (item as any).turnover ? Number((item as any).turnover) : undefined,
|
||||
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
|
||||
chartType,
|
||||
dataLength: rawData.length,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -90,7 +84,6 @@ const parseTimestamp = (
|
||||
}
|
||||
|
||||
// 默认返回当前时间(避免图表崩溃)
|
||||
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
@@ -109,7 +102,6 @@ const parseTimelineTimestamp = (time: string, eventTime: string): number => {
|
||||
const eventDate = dayjs(eventTime).startOf('day');
|
||||
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
|
||||
return dayjs(eventTime).valueOf();
|
||||
}
|
||||
};
|
||||
@@ -126,19 +118,16 @@ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] =
|
||||
return data.filter((item) => {
|
||||
// 移除价格为 0 或负数的数据
|
||||
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除 high < low 的数据(数据错误)
|
||||
if (item.high < item.low) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除成交量为负数的数据
|
||||
if (item.volume < 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -213,17 +202,8 @@ export const trimDataByEventTime = (
|
||||
return item.timestamp >= startTime && item.timestamp <= endTime;
|
||||
});
|
||||
|
||||
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
|
||||
originalLength: data.length,
|
||||
trimmedLength: trimmedData.length,
|
||||
eventTime,
|
||||
chartType,
|
||||
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
|
||||
});
|
||||
|
||||
return trimmedData;
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
|
||||
return data; // 出错时返回原始数据
|
||||
}
|
||||
};
|
||||
@@ -260,13 +240,6 @@ export const processChartData = (
|
||||
data = trimDataByEventTime(data, eventTime, chartType);
|
||||
}
|
||||
|
||||
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
|
||||
rawLength: rawData.length,
|
||||
processedLength: data.length,
|
||||
chartType,
|
||||
hasEventTime: !!eventTime,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { OverlayCreate } from 'klinecharts';
|
||||
import type { EventMarker, KLineDataPoint } from '../types';
|
||||
import { EVENT_MARKER_CONFIG } from '../config';
|
||||
import { findClosestDataPoint } from './dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 创建事件标记 Overlay(KLineChart 10.0 格式)
|
||||
@@ -27,10 +26,6 @@ export const createEventMarkerOverlay = (
|
||||
const closestPoint = findClosestDataPoint(data, marker.timestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
|
||||
markerId: marker.id,
|
||||
timestamp: marker.timestamp,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -64,10 +59,6 @@ export const createEventMarkerOverlay = (
|
||||
style: 'fill',
|
||||
color: marker.color,
|
||||
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
|
||||
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingRight: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingTop: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
|
||||
},
|
||||
},
|
||||
// 标记文本内容
|
||||
@@ -77,17 +68,8 @@ export const createEventMarkerOverlay = (
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
|
||||
markerId: marker.id,
|
||||
timestamp: closestPoint.timestamp,
|
||||
label: marker.label,
|
||||
});
|
||||
|
||||
return overlay;
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
|
||||
markerId: marker.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -108,7 +90,6 @@ export const createEventHighlightOverlay = (
|
||||
const closestPoint = findClosestDataPoint(data, eventTimestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -135,14 +116,8 @@ export const createEventHighlightOverlay = (
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
|
||||
timestamp: closestPoint.timestamp,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
return overlay;
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -219,11 +194,6 @@ export const createEventMarkerOverlays = (
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
|
||||
totalMarkers: markers.length,
|
||||
createdOverlays: overlays.length,
|
||||
});
|
||||
|
||||
return overlays;
|
||||
};
|
||||
|
||||
@@ -235,10 +205,9 @@ export const createEventMarkerOverlays = (
|
||||
*/
|
||||
export const removeEventMarker = (chart: any, markerId: string): void => {
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
|
||||
chart.removeOverlay({ id: markerId });
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
|
||||
// 忽略移除标记时的错误
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,9 +220,8 @@ export const removeAllEventMarkers = (chart: any): void => {
|
||||
try {
|
||||
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
|
||||
chart.removeOverlay();
|
||||
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
|
||||
// 忽略移除所有标记时的错误
|
||||
}
|
||||
};
|
||||
|
||||
@@ -275,13 +243,8 @@ export const updateEventMarker = (
|
||||
|
||||
// 重新创建标记(KLineChart 10.0 不支持直接更新 overlay)
|
||||
// 注意:需要在调用方重新创建并添加 overlay
|
||||
|
||||
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
|
||||
markerId,
|
||||
updates,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
|
||||
// 忽略更新标记时的错误
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,12 +272,8 @@ export const highlightEventMarker = (
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
|
||||
markerId,
|
||||
highlight,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
|
||||
// 忽略高亮标记时的错误
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,61 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { logger } from '@utils/logger';
|
||||
import { performanceMonitor } from '@utils/performanceMonitor';
|
||||
import { useNotification } from '@contexts/NotificationContext';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
|
||||
let posthogModule = null;
|
||||
|
||||
/**
|
||||
* ⚡ 延迟加载 PostHog 模块
|
||||
*/
|
||||
const loadPostHogModule = async () => {
|
||||
if (posthogModule) return posthogModule;
|
||||
|
||||
try {
|
||||
posthogModule = await import('@lib/posthog');
|
||||
return posthogModule;
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'PostHog 模块加载失败', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 identifyUser
|
||||
*/
|
||||
const identifyUserLazy = async (userId, userProperties) => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.identifyUser(userId, userProperties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 resetUser
|
||||
*/
|
||||
const resetUserLazy = async () => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.resetUser();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 trackEvent(使用异步版本)
|
||||
*/
|
||||
const trackEventLazy = async (eventName, properties) => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.trackEventAsync(eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
|
||||
@@ -37,6 +87,9 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// 检查Session状态
|
||||
const checkSession = async () => {
|
||||
// ⚡ 性能标记:认证检查开始
|
||||
performanceMonitor.mark('auth-check-start');
|
||||
|
||||
// 节流检查
|
||||
const now = Date.now();
|
||||
const timeSinceLastCheck = now - lastCheckTimeRef.current;
|
||||
@@ -47,6 +100,8 @@ export const AuthProvider = ({ children }) => {
|
||||
minInterval: `${MIN_CHECK_INTERVAL}ms`,
|
||||
reason: '距离上次请求间隔太短'
|
||||
});
|
||||
// ⚡ 性能标记:认证检查结束(节流情况)
|
||||
performanceMonitor.mark('auth-check-end');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,8 +148,8 @@ export const AuthProvider = ({ children }) => {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
// ✅ 识别用户身份到 PostHog(延迟加载)
|
||||
identifyUserLazy(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
@@ -125,6 +180,8 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser((prev) => prev === null ? prev : null);
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
} finally {
|
||||
// ⚡ 性能标记:认证检查结束
|
||||
performanceMonitor.mark('auth-check-end');
|
||||
// ⚡ 只在 isLoading 为 true 时才设置为 false,避免不必要的状态更新
|
||||
setIsLoading((prev) => prev === false ? prev : false);
|
||||
}
|
||||
@@ -346,8 +403,8 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
// ✅ 追踪登出事件(延迟加载,必须在 resetUser() 之前)
|
||||
trackEventLazy(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
@@ -355,8 +412,8 @@ export const AuthProvider = ({ children }) => {
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
// ✅ 重置 PostHog 用户会话(延迟加载)
|
||||
resetUserLazy();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectIsMobile } from '@/store/slices/deviceSlice';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
@@ -27,6 +29,9 @@ const CONNECTION_STATUS = {
|
||||
RECONNECTED: 'reconnected', // 重连成功(显示2秒后自动变回 CONNECTED)
|
||||
};
|
||||
|
||||
// ⚡ 模块级变量:防止 React Strict Mode 导致的重复初始化
|
||||
let socketInitialized = false;
|
||||
|
||||
// 创建通知上下文
|
||||
const NotificationContext = createContext();
|
||||
|
||||
@@ -41,6 +46,42 @@ export const useNotification = () => {
|
||||
|
||||
// 通知提供者组件
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
// ⚡ 移动端检测(使用 Redux 状态)
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// ⚡ 移动端禁用完整通知能力:返回空壳 Provider
|
||||
// 移动端不支持桌面通知,且不需要 Socket 实时推送
|
||||
if (isMobile) {
|
||||
const emptyValue = {
|
||||
notifications: [],
|
||||
isConnected: false,
|
||||
soundEnabled: false,
|
||||
browserPermission: 'default',
|
||||
connectionStatus: CONNECTION_STATUS.DISCONNECTED,
|
||||
reconnectAttempt: 0,
|
||||
maxReconnectAttempts: 0,
|
||||
addNotification: () => null,
|
||||
removeNotification: () => {},
|
||||
clearAllNotifications: () => {},
|
||||
toggleSound: () => {},
|
||||
requestBrowserPermission: () => Promise.resolve('default'),
|
||||
trackNotificationClick: () => {},
|
||||
retryConnection: () => {},
|
||||
showWelcomeGuide: () => {},
|
||||
showCommunityGuide: () => {},
|
||||
showFirstFollowGuide: () => {},
|
||||
registerEventUpdateCallback: () => () => {},
|
||||
unregisterEventUpdateCallback: () => {},
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={emptyValue}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 桌面端:完整通知功能 ==========
|
||||
const toast = useToast();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
@@ -649,183 +690,223 @@ export const NotificationProvider = ({ children }) => {
|
||||
}, [adaptEventToNotification]);
|
||||
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
|
||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', '初始化 Socket 连接(方案2:只注册一次)');
|
||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||
if (socketInitialized) {
|
||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
let cleanupCalled = false;
|
||||
let idleCallbackId;
|
||||
let timeoutId;
|
||||
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
logger.info('NotificationContext', '首次连接成功', {
|
||||
socketId: socket.getSocketId?.()
|
||||
});
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
} else {
|
||||
logger.info('NotificationContext', '重连成功');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
// ⚡ Socket 初始化函数(将在浏览器空闲时执行)
|
||||
const initSocketConnection = () => {
|
||||
if (cleanupCalled || socketInitialized) return; // 防止组件卸载后执行或重复初始化
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
clearTimeout(reconnectedTimerRef.current);
|
||||
socketInitialized = true; // 标记已初始化
|
||||
logger.info('NotificationContext', '初始化 Socket 连接(异步执行,不阻塞首屏)');
|
||||
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
logger.info('NotificationContext', '首次连接成功', {
|
||||
socketId: socket.getSocketId?.()
|
||||
});
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
} else {
|
||||
logger.info('NotificationContext', '重连成功');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
clearTimeout(reconnectedTimerRef.current);
|
||||
}
|
||||
|
||||
// 2秒后自动变回 CONNECTED
|
||||
reconnectedTimerRef.current = setTimeout(() => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 2秒后自动变回 CONNECTED
|
||||
reconnectedTimerRef.current = setTimeout(() => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||||
}, 2000);
|
||||
}
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
// 使用 setTimeout(0) 确保 socketService 内部状态已同步
|
||||
setTimeout(() => {
|
||||
logger.info('NotificationContext', '重新订阅事件推送');
|
||||
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
logger.info('NotificationContext', '重新订阅事件推送');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
logger.info('NotificationContext', '订阅成功', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 监听断开连接 ==========
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket 已断开', { reason });
|
||||
});
|
||||
|
||||
// ========== 监听连接错误 ==========
|
||||
socket.on('connect_error', (error) => {
|
||||
logger.error('NotificationContext', 'Socket connect_error', error);
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
|
||||
});
|
||||
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', '重连失败');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
status: 'error',
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
logger.info('NotificationContext', '收到 new_event 事件', {
|
||||
id: data?.id,
|
||||
title: data?.title,
|
||||
eventType: data?.event_type || data?.type,
|
||||
importance: data?.importance
|
||||
});
|
||||
logger.debug('NotificationContext', '原始事件数据', data);
|
||||
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (!data.id) {
|
||||
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
|
||||
eventId,
|
||||
eventType: data.type,
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
|
||||
return;
|
||||
}
|
||||
|
||||
processedEventIds.current.add(eventId);
|
||||
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
|
||||
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
const idsArray = Array.from(processedEventIds.current);
|
||||
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
|
||||
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
|
||||
kept: MAX_PROCESSED_IDS,
|
||||
});
|
||||
}
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
logger.debug('NotificationContext', '正在转换事件格式');
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
logger.debug('NotificationContext', '转换后的通知对象', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
logger.debug('NotificationContext', '准备添加通知到队列');
|
||||
addNotificationRef.current(notification);
|
||||
logger.info('NotificationContext', '通知已添加到队列');
|
||||
|
||||
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
|
||||
if (eventUpdateCallbacks.current.size > 0) {
|
||||
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
|
||||
eventUpdateCallbacks.current.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', '事件更新回调执行失败', error);
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
logger.info('NotificationContext', '订阅成功', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// ========== 监听断开连接 ==========
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket 已断开', { reason });
|
||||
});
|
||||
|
||||
// ========== 监听连接错误 ==========
|
||||
socket.on('connect_error', (error) => {
|
||||
logger.error('NotificationContext', 'Socket connect_error', error);
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
|
||||
});
|
||||
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', '重连失败');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
status: 'error',
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
logger.debug('NotificationContext', '所有事件更新回调已触发');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', '收到系统通知', data);
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
logger.info('NotificationContext', '收到 new_event 事件', {
|
||||
id: data?.id,
|
||||
title: data?.title,
|
||||
eventType: data?.event_type || data?.type,
|
||||
importance: data?.importance
|
||||
});
|
||||
logger.debug('NotificationContext', '原始事件数据', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
logger.error('NotificationContext', 'addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
if (!data.id) {
|
||||
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
|
||||
eventId,
|
||||
eventType: data.type,
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 启动连接 ==========
|
||||
logger.info('NotificationContext', '调用 socket.connect()');
|
||||
socket.connect();
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
|
||||
return;
|
||||
}
|
||||
|
||||
processedEventIds.current.add(eventId);
|
||||
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
|
||||
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
const idsArray = Array.from(processedEventIds.current);
|
||||
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
|
||||
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
|
||||
kept: MAX_PROCESSED_IDS,
|
||||
});
|
||||
}
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
logger.debug('NotificationContext', '正在转换事件格式');
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
logger.debug('NotificationContext', '转换后的通知对象', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
logger.debug('NotificationContext', '准备添加通知到队列');
|
||||
addNotificationRef.current(notification);
|
||||
logger.info('NotificationContext', '通知已添加到队列');
|
||||
|
||||
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
|
||||
if (eventUpdateCallbacks.current.size > 0) {
|
||||
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
|
||||
eventUpdateCallbacks.current.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', '事件更新回调执行失败', error);
|
||||
}
|
||||
});
|
||||
logger.debug('NotificationContext', '所有事件更新回调已触发');
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', '收到系统通知', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
logger.error('NotificationContext', 'addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('NotificationContext', '所有监听器已注册');
|
||||
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ========== 启动连接 ==========
|
||||
logger.info('NotificationContext', '调用 socket.connect()');
|
||||
socket.connect();
|
||||
};
|
||||
|
||||
// ⚡ 使用 requestIdleCallback 在浏览器空闲时初始化 Socket
|
||||
// 降级到 setTimeout(0) 以兼容不支持的浏览器(如 Safari)
|
||||
if ('requestIdleCallback' in window) {
|
||||
idleCallbackId = window.requestIdleCallback(initSocketConnection, {
|
||||
timeout: 3000 // 最多等待 3 秒,确保连接不会延迟太久
|
||||
});
|
||||
logger.debug('NotificationContext', 'Socket 初始化已排入 requestIdleCallback');
|
||||
} else {
|
||||
timeoutId = setTimeout(initSocketConnection, 0);
|
||||
logger.debug('NotificationContext', 'Socket 初始化已排入 setTimeout(0)(降级模式)');
|
||||
}
|
||||
|
||||
// ========== 清理函数(组件卸载时) ==========
|
||||
return () => {
|
||||
cleanupCalled = true;
|
||||
logger.info('NotificationContext', '清理 Socket 连接');
|
||||
|
||||
// 取消待执行的初始化
|
||||
if (idleCallbackId && 'cancelIdleCallback' in window) {
|
||||
window.cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
clearTimeout(reconnectedTimerRef.current);
|
||||
|
||||
@@ -82,7 +82,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
...getBaseProperties(),
|
||||
source,
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 手机验证码流程 ====================
|
||||
@@ -186,7 +185,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
has_auth_url: Boolean(authUrl),
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -198,7 +196,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -212,7 +209,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
time_elapsed: timeElapsed,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -226,7 +222,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
old_session_id: oldSessionId?.substring(0, 8) + '...',
|
||||
new_session_id: newSessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -242,7 +237,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
old_status: oldStatus,
|
||||
new_status: newStatus,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -250,7 +244,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
*/
|
||||
const trackWechatH5Redirect = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 登录/注册结果 ====================
|
||||
|
||||
35
src/hooks/useDevice.js
Normal file
35
src/hooks/useDevice.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* useDevice Hook
|
||||
*
|
||||
* 封装设备类型检测,提供简洁的 API 供组件使用
|
||||
*
|
||||
* @example
|
||||
* const { isMobile, isTablet, isDesktop, deviceType } = useDevice();
|
||||
*
|
||||
* if (isMobile) return <MobileView />;
|
||||
* if (isTablet) return <TabletView />;
|
||||
* return <DesktopView />;
|
||||
*/
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
selectIsMobile,
|
||||
selectIsTablet,
|
||||
selectIsDesktop,
|
||||
selectDeviceType,
|
||||
} from '@/store/slices/deviceSlice';
|
||||
|
||||
export const useDevice = () => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
const isTablet = useSelector(selectIsTablet);
|
||||
const isDesktop = useSelector(selectIsDesktop);
|
||||
const deviceType = useSelector(selectDeviceType);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
deviceType,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDevice;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resetToFree,
|
||||
selectSubscriptionInfo,
|
||||
selectSubscriptionLoading,
|
||||
selectSubscriptionLoaded,
|
||||
selectSubscriptionError,
|
||||
selectSubscriptionModalOpen
|
||||
} from '../store/slices/subscriptionSlice';
|
||||
@@ -66,21 +67,24 @@ export const useSubscription = () => {
|
||||
// Redux 状态
|
||||
const subscriptionInfo = useSelector(selectSubscriptionInfo);
|
||||
const loading = useSelector(selectSubscriptionLoading);
|
||||
const loaded = useSelector(selectSubscriptionLoaded);
|
||||
const error = useSelector(selectSubscriptionError);
|
||||
const isSubscriptionModalOpen = useSelector(selectSubscriptionModalOpen);
|
||||
|
||||
// 自动加载订阅信息
|
||||
// 自动加载订阅信息(带防重复逻辑)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
// 用户已登录,加载订阅信息
|
||||
dispatch(fetchSubscriptionInfo());
|
||||
logger.debug('useSubscription', '加载订阅信息', { userId: user.id });
|
||||
// 只在未加载且未在加载中时才请求,避免多个组件重复调用
|
||||
if (!loaded && !loading) {
|
||||
dispatch(fetchSubscriptionInfo());
|
||||
logger.debug('useSubscription', '加载订阅信息', { userId: user.id });
|
||||
}
|
||||
} else {
|
||||
// 用户未登录,重置为免费版
|
||||
dispatch(resetToFree());
|
||||
logger.debug('useSubscription', '用户未登录,重置为免费版');
|
||||
}
|
||||
}, [isAuthenticated, user, dispatch]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, user?.id, dispatch, loaded, loading]);
|
||||
|
||||
// 获取订阅级别数值
|
||||
const getSubscriptionLevel = (type = null) => {
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// src/hooks/useSubscriptionEvents.js
|
||||
// 订阅和支付事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 订阅和支付事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Object} options.currentSubscription - 当前订阅信息
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
* @param {string} feature - 被限制的功能名称
|
||||
* @param {string} requiredPlan - 需要的订阅计划
|
||||
* @param {string} triggerLocation - 触发位置
|
||||
*/
|
||||
const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_plan: requiredPlan,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
trigger_location: triggerLocation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', {
|
||||
feature,
|
||||
requiredPlan,
|
||||
triggerLocation,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪付费墙关闭
|
||||
* @param {string} feature - 功能名称
|
||||
* @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button')
|
||||
*/
|
||||
const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
||||
feature,
|
||||
close_method: closeMethod,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', {
|
||||
feature,
|
||||
closeMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} targetPlan - 目标订阅计划
|
||||
* @param {string} source - 来源位置
|
||||
* @param {string} feature - 关联的功能(如果从付费墙点击)
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
feature: feature || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
targetPlan,
|
||||
source,
|
||||
feature,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅页面查看
|
||||
* @param {string} source - 来源
|
||||
*/
|
||||
const trackSubscriptionPageViewed = useCallback((source = '') => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
subscription_status: currentSubscription?.status || 'unknown',
|
||||
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
source,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划查看
|
||||
* @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanViewed = useCallback((planName, price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Viewed', {
|
||||
plan_name: planName,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', {
|
||||
planName,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划选择
|
||||
* @param {string} planName - 选择的计划名称
|
||||
* @param {string} billingCycle - 计费周期 ('monthly' | 'yearly')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Selected', {
|
||||
plan_name: planName,
|
||||
billing_cycle: billingCycle,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', {
|
||||
planName,
|
||||
billingCycle,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付页面查看
|
||||
* @param {string} planName - 购买的计划
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentPageViewed = useCallback((planName, amount = 0) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
||||
plan_name: planName,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', {
|
||||
planName,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付方式选择
|
||||
* @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card')
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => {
|
||||
if (!paymentMethod) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
||||
payment_method: paymentMethod,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', {
|
||||
paymentMethod,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付发起
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} paymentInfo.planName - 计划名称
|
||||
* @param {string} paymentInfo.paymentMethod - 支付方式
|
||||
* @param {number} paymentInfo.amount - 金额
|
||||
* @param {string} paymentInfo.billingCycle - 计费周期
|
||||
* @param {string} paymentInfo.orderId - 订单ID
|
||||
*/
|
||||
const trackPaymentInitiated = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
paymentMethod: paymentInfo.paymentMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付成功
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
*/
|
||||
const trackPaymentSuccessful = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
transaction_id: paymentInfo.transactionId,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Payment Successful', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付失败
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} errorReason - 失败原因
|
||||
*/
|
||||
const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => {
|
||||
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
error_reason: errorReason,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Payment Failed', {
|
||||
planName: paymentInfo.planName,
|
||||
errorReason,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅创建成功
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionCreated = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
||||
plan_name: subscription.plan,
|
||||
billing_cycle: subscription.billingCycle,
|
||||
amount: subscription.amount,
|
||||
start_date: subscription.startDate,
|
||||
end_date: subscription.endDate,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🎉 Subscription Created', {
|
||||
plan: subscription.plan,
|
||||
billingCycle: subscription.billingCycle,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅续费
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionRenewed = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
||||
plan_name: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
previous_end_date: subscription.previousEndDate,
|
||||
new_end_date: subscription.newEndDate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', {
|
||||
plan: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅取消
|
||||
* @param {string} reason - 取消原因
|
||||
* @param {boolean} cancelImmediately - 是否立即取消
|
||||
*/
|
||||
const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
||||
plan_name: currentSubscription?.plan,
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
cancel_immediately: cancelImmediately,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', {
|
||||
plan: currentSubscription?.plan,
|
||||
reason,
|
||||
cancelImmediately,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪优惠券应用
|
||||
* @param {string} couponCode - 优惠券代码
|
||||
* @param {number} discountAmount - 折扣金额
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => {
|
||||
if (!couponCode) {
|
||||
logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Coupon Applied', {
|
||||
coupon_code: couponCode,
|
||||
discount_amount: discountAmount,
|
||||
success,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', {
|
||||
couponCode,
|
||||
discountAmount,
|
||||
success,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
return {
|
||||
// 付费墙事件
|
||||
trackPaywallShown,
|
||||
trackPaywallDismissed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 订阅页面事件
|
||||
trackSubscriptionPageViewed,
|
||||
trackPricingPlanViewed,
|
||||
trackPricingPlanSelected,
|
||||
|
||||
// 支付流程事件
|
||||
trackPaymentPageViewed,
|
||||
trackPaymentMethodSelected,
|
||||
trackPaymentInitiated,
|
||||
trackPaymentSuccessful,
|
||||
trackPaymentFailed,
|
||||
|
||||
// 订阅管理事件
|
||||
trackSubscriptionCreated,
|
||||
trackSubscriptionRenewed,
|
||||
trackSubscriptionCancelled,
|
||||
|
||||
// 优惠券事件
|
||||
trackCouponApplied,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscriptionEvents;
|
||||
382
src/hooks/useSubscriptionEvents.ts
Normal file
382
src/hooks/useSubscriptionEvents.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
// src/hooks/useSubscriptionEvents.ts
|
||||
// 订阅和支付事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
||||
|
||||
/**
|
||||
* 当前订阅信息
|
||||
*/
|
||||
interface SubscriptionInfo {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSubscriptionEvents Hook 配置选项
|
||||
*/
|
||||
interface UseSubscriptionEventsOptions {
|
||||
currentSubscription?: SubscriptionInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付信息
|
||||
*/
|
||||
interface PaymentInfo {
|
||||
planName?: string;
|
||||
paymentMethod?: string;
|
||||
amount?: number;
|
||||
billingCycle?: string;
|
||||
orderId?: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅信息
|
||||
*/
|
||||
interface SubscriptionData {
|
||||
plan?: string;
|
||||
billingCycle?: string;
|
||||
amount?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
previousEndDate?: string;
|
||||
newEndDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSubscriptionEvents Hook 返回值
|
||||
*/
|
||||
interface UseSubscriptionEventsReturn {
|
||||
trackPaywallShown: (feature: string, requiredPlan?: string, triggerLocation?: string) => void;
|
||||
trackPaywallDismissed: (feature: string, closeMethod?: string) => void;
|
||||
trackUpgradePlanClicked: (targetPlan?: string, source?: string, feature?: string) => void;
|
||||
trackSubscriptionPageViewed: (source?: string) => void;
|
||||
trackPricingPlanViewed: (planName: string, price?: number) => void;
|
||||
trackPricingPlanSelected: (planName: string, billingCycle?: string, price?: number) => void;
|
||||
trackPaymentPageViewed: (planName: string, amount?: number) => void;
|
||||
trackPaymentMethodSelected: (paymentMethod: string, amount?: number) => void;
|
||||
trackPaymentInitiated: (paymentInfo?: PaymentInfo) => void;
|
||||
trackPaymentSuccessful: (paymentInfo?: PaymentInfo) => void;
|
||||
trackPaymentFailed: (paymentInfo?: PaymentInfo, errorReason?: string) => void;
|
||||
trackSubscriptionCreated: (subscription?: SubscriptionData) => void;
|
||||
trackSubscriptionRenewed: (subscription?: SubscriptionData) => void;
|
||||
trackSubscriptionCancelled: (reason?: string, cancelImmediately?: boolean) => void;
|
||||
trackCouponApplied: (couponCode: string, discountAmount?: number, success?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅和支付事件追踪 Hook
|
||||
* @param options - 配置选项
|
||||
* @returns 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSubscriptionEvents = ({
|
||||
currentSubscription = null,
|
||||
}: UseSubscriptionEventsOptions = {}): UseSubscriptionEventsReturn => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
*/
|
||||
const trackPaywallShown = useCallback(
|
||||
(feature: string, requiredPlan: string = 'pro', triggerLocation: string = '') => {
|
||||
if (!feature) {
|
||||
console.warn('useSubscriptionEvents: trackPaywallShown - feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_plan: requiredPlan,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
trigger_location: triggerLocation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪付费墙关闭
|
||||
*/
|
||||
const trackPaywallDismissed = useCallback(
|
||||
(feature: string, closeMethod: string = 'dismiss') => {
|
||||
if (!feature) {
|
||||
console.warn('useSubscriptionEvents: trackPaywallDismissed - feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
||||
feature,
|
||||
close_method: closeMethod,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback(
|
||||
(targetPlan: string = 'pro', source: string = '', feature: string = '') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
feature: feature || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪订阅页面查看
|
||||
*/
|
||||
const trackSubscriptionPageViewed = useCallback(
|
||||
(source: string = '') => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
subscription_status: currentSubscription?.status || 'unknown',
|
||||
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪定价计划查看
|
||||
*/
|
||||
const trackPricingPlanViewed = useCallback(
|
||||
(planName: string, price: number = 0) => {
|
||||
if (!planName) {
|
||||
console.warn('useSubscriptionEvents: trackPricingPlanViewed - planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Viewed', {
|
||||
plan_name: planName,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪定价计划选择
|
||||
*/
|
||||
const trackPricingPlanSelected = useCallback(
|
||||
(planName: string, billingCycle: string = 'monthly', price: number = 0) => {
|
||||
if (!planName) {
|
||||
console.warn('useSubscriptionEvents: trackPricingPlanSelected - planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Selected', {
|
||||
plan_name: planName,
|
||||
billing_cycle: billingCycle,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪支付页面查看
|
||||
*/
|
||||
const trackPaymentPageViewed = useCallback(
|
||||
(planName: string, amount: number = 0) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
||||
plan_name: planName,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪支付方式选择
|
||||
*/
|
||||
const trackPaymentMethodSelected = useCallback(
|
||||
(paymentMethod: string, amount: number = 0) => {
|
||||
if (!paymentMethod) {
|
||||
console.warn('useSubscriptionEvents: trackPaymentMethodSelected - paymentMethod is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
||||
payment_method: paymentMethod,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪支付发起
|
||||
*/
|
||||
const trackPaymentInitiated = useCallback(
|
||||
(paymentInfo: PaymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪支付成功
|
||||
*/
|
||||
const trackPaymentSuccessful = useCallback(
|
||||
(paymentInfo: PaymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
transaction_id: paymentInfo.transactionId,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪支付失败
|
||||
*/
|
||||
const trackPaymentFailed = useCallback(
|
||||
(paymentInfo: PaymentInfo = {}, errorReason: string = '') => {
|
||||
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
error_reason: errorReason,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪订阅创建成功
|
||||
*/
|
||||
const trackSubscriptionCreated = useCallback(
|
||||
(subscription: SubscriptionData = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
||||
plan_name: subscription.plan,
|
||||
billing_cycle: subscription.billingCycle,
|
||||
amount: subscription.amount,
|
||||
start_date: subscription.startDate,
|
||||
end_date: subscription.endDate,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪订阅续费
|
||||
*/
|
||||
const trackSubscriptionRenewed = useCallback(
|
||||
(subscription: SubscriptionData = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
||||
plan_name: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
previous_end_date: subscription.previousEndDate,
|
||||
new_end_date: subscription.newEndDate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪订阅取消
|
||||
*/
|
||||
const trackSubscriptionCancelled = useCallback(
|
||||
(reason: string = '', cancelImmediately: boolean = false) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
||||
plan_name: currentSubscription?.plan,
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
cancel_immediately: cancelImmediately,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪优惠券应用
|
||||
*/
|
||||
const trackCouponApplied = useCallback(
|
||||
(couponCode: string, discountAmount: number = 0, success: boolean = true) => {
|
||||
if (!couponCode) {
|
||||
console.warn('useSubscriptionEvents: trackCouponApplied - couponCode is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Coupon Applied', {
|
||||
coupon_code: couponCode,
|
||||
discount_amount: discountAmount,
|
||||
success,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[track, currentSubscription]
|
||||
);
|
||||
|
||||
return {
|
||||
// 付费墙事件
|
||||
trackPaywallShown,
|
||||
trackPaywallDismissed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 订阅页面事件
|
||||
trackSubscriptionPageViewed,
|
||||
trackPricingPlanViewed,
|
||||
trackPricingPlanSelected,
|
||||
|
||||
// 支付流程事件
|
||||
trackPaymentPageViewed,
|
||||
trackPaymentMethodSelected,
|
||||
trackPaymentInitiated,
|
||||
trackPaymentSuccessful,
|
||||
trackPaymentFailed,
|
||||
|
||||
// 订阅管理事件
|
||||
trackSubscriptionCreated,
|
||||
trackSubscriptionRenewed,
|
||||
trackSubscriptionCancelled,
|
||||
|
||||
// 优惠券事件
|
||||
trackCouponApplied,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscriptionEvents;
|
||||
46
src/index.js
46
src/index.js
@@ -3,8 +3,12 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
// 导入 Brainwave 样式(空文件,保留以避免错误)
|
||||
import './styles/brainwave.css';
|
||||
// ⚡ 性能监控:在应用启动时尽早标记
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
performanceMonitor.mark('app-start');
|
||||
|
||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||
// import './styles/brainwave.css';
|
||||
|
||||
// 导入 Select 下拉框颜色修复样式
|
||||
import './styles/select-fix.css';
|
||||
@@ -115,19 +119,13 @@ function registerServiceWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动 Mock Service Worker(如果启用)
|
||||
async function startApp() {
|
||||
// 只在开发环境启动 MSW
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
const { startMockServiceWorker } = await import('./mocks/browser');
|
||||
await startMockServiceWorker();
|
||||
}
|
||||
|
||||
// 启动应用(MSW 异步初始化,不阻塞首屏渲染)
|
||||
function startApp() {
|
||||
// Create root
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
// ✅ StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
// ✅ 先渲染应用,不等待 MSW
|
||||
// StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router
|
||||
@@ -141,6 +139,30 @@ async function startApp() {
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// ✅ 后台异步启动 MSW(不阻塞首屏渲染)
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
const initMSW = async () => {
|
||||
try {
|
||||
const { startMockServiceWorker } = await import('./mocks/browser');
|
||||
await startMockServiceWorker();
|
||||
console.log(
|
||||
'%c[MSW] ✅ Mock Service Worker 已在后台启动',
|
||||
'color: #4CAF50; font-weight: bold;'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[MSW] ❌ Mock Service Worker 启动失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 requestIdleCallback 在浏览器空闲时初始化,不阻塞首屏
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => initMSW(), { timeout: 3000 });
|
||||
} else {
|
||||
// 降级:使用 setTimeout(0) 延迟执行
|
||||
setTimeout(initMSW, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 Service Worker
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function MainLayout() {
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="72px">
|
||||
<Box flex="1" pt="60px">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -102,7 +102,6 @@ export function setCurrentUser(user) {
|
||||
subscription_days_left: user.subscription_days_left || 0
|
||||
};
|
||||
localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser));
|
||||
console.log('[Mock State] 设置当前登录用户:', normalizedUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -613,14 +613,6 @@ export const accountHandlers = [
|
||||
end_date: currentUser.subscription_end_date || null
|
||||
};
|
||||
|
||||
console.log('[Mock API] 获取当前订阅详情:', {
|
||||
user_id: currentUser.id,
|
||||
phone: currentUser.phone,
|
||||
subscription_type: userSubscriptionType,
|
||||
subscription_status: subscriptionDetails.status,
|
||||
days_left: subscriptionDetails.days_left
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: subscriptionDetails
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 500;
|
||||
// ⚡ 开发环境使用较短延迟,加快首屏加载速度
|
||||
const NETWORK_DELAY = 50;
|
||||
|
||||
export const authHandlers = [
|
||||
// ==================== 手机验证码登录 ====================
|
||||
@@ -31,21 +32,6 @@ export const authHandlers = [
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 验证码: ${code.padEnd(22)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${credential}(Mock: ${code})`,
|
||||
@@ -141,8 +127,6 @@ export const authHandlers = [
|
||||
const body = await request.json();
|
||||
const { credential, verification_code, login_type } = body;
|
||||
|
||||
console.log('[Mock] 验证码登录:', { credential, verification_code, login_type });
|
||||
|
||||
// 验证验证码
|
||||
const storedCode = mockVerificationCodes.get(credential);
|
||||
if (!storedCode) {
|
||||
@@ -194,11 +178,8 @@ export const authHandlers = [
|
||||
subscription_days_left: 0
|
||||
};
|
||||
mockUsers[credential] = user;
|
||||
console.log('[Mock] 创建新用户:', user);
|
||||
}
|
||||
|
||||
console.log('[Mock] 登录成功:', user);
|
||||
|
||||
// 设置当前登录用户
|
||||
setCurrentUser(user);
|
||||
|
||||
@@ -345,25 +326,22 @@ export const authHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 获取微信 H5 授权 URL
|
||||
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => {
|
||||
// 6. 获取微信 H5 授权 URL(手机浏览器用)
|
||||
http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { redirect_url } = body;
|
||||
|
||||
const state = generateWechatSessionId();
|
||||
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`;
|
||||
// Mock 模式下直接返回前端回调地址(模拟授权成功)
|
||||
const authUrl = `${redirect_url}?wechat_login=success&state=${state}`;
|
||||
|
||||
console.log('[Mock] 生成微信 H5 授权 URL:', authUrl);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
message: '成功',
|
||||
data: {
|
||||
auth_url: authUrl,
|
||||
state
|
||||
}
|
||||
auth_url: authUrl,
|
||||
state
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -371,13 +349,11 @@ export const authHandlers = [
|
||||
|
||||
// 7. 检查 Session(AuthContext 使用的正确端点)
|
||||
http.get('/api/auth/session', async () => {
|
||||
await delay(300);
|
||||
await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
|
||||
|
||||
// 获取当前登录用户
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
console.log('[Mock] 检查 Session:', currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -395,13 +371,11 @@ export const authHandlers = [
|
||||
|
||||
// 8. 检查 Session(旧端点,保留兼容)
|
||||
http.get('/api/auth/check-session', async () => {
|
||||
await delay(300);
|
||||
await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
|
||||
|
||||
// 获取当前登录用户
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
console.log('[Mock] 检查 Session (旧端点):', currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -432,91 +406,3 @@ export const authHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发微信扫码模拟
|
||||
* 使用方式:
|
||||
* 1. 浏览器控制台输入:window.mockWechatScan()
|
||||
* 2. 或者在组件中调用:window.mockWechatScan(sessionId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockWechatScan = (sessionId) => {
|
||||
// 如果没有传入sessionId,尝试获取最新的session
|
||||
let targetSessionId = sessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
// 获取最新创建的session
|
||||
const sessions = Array.from(mockWechatSessions.entries());
|
||||
if (sessions.length === 0) {
|
||||
console.warn('[Mock API] 没有活跃的微信session,请先获取二维码');
|
||||
return false;
|
||||
}
|
||||
// 按创建时间排序,获取最新的
|
||||
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
|
||||
targetSessionId = latestSession[0];
|
||||
}
|
||||
|
||||
const session = mockWechatSessions.get(targetSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.error('[Mock API] Session不存在:', targetSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status !== 'waiting') {
|
||||
console.warn('[Mock API] Session状态不是waiting,当前状态:', session.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 立即触发扫码
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
|
||||
|
||||
// 1秒后自动确认登录
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString(),
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露获取当前sessions的方法(调试用)
|
||||
window.getMockWechatSessions = () => {
|
||||
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
|
||||
sessionId: id,
|
||||
status: session.status,
|
||||
createdAt: new Date(session.createdAt).toLocaleString(),
|
||||
hasUser: !!session.user
|
||||
}));
|
||||
console.table(sessions);
|
||||
return sessions;
|
||||
};
|
||||
|
||||
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockWechatScan() - 触发最新session的扫码');
|
||||
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
|
||||
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
|
||||
}
|
||||
|
||||
22
src/mocks/handlers/bytedesk.js
Normal file
22
src/mocks/handlers/bytedesk.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/mocks/handlers/bytedesk.js
|
||||
/**
|
||||
* Bytedesk 客服 Widget MSW Handler
|
||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
||||
*/
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
|
||||
export const bytedeskHandlers = [
|
||||
// Bytedesk API 请求 - 直接 passthrough
|
||||
// 匹配 /bytedesk/* 路径(通过代理访问后端)
|
||||
http.all('/bytedesk/*', () => {
|
||||
return passthrough();
|
||||
}),
|
||||
|
||||
// Bytedesk 外部 CDN/服务请求
|
||||
http.all('https://www.weiyuai.cn/*', () => {
|
||||
return passthrough();
|
||||
}),
|
||||
];
|
||||
|
||||
export default bytedeskHandlers;
|
||||
@@ -16,6 +16,7 @@ import { limitAnalyseHandlers } from './limitAnalyse';
|
||||
import { posthogHandlers } from './posthog';
|
||||
import { externalHandlers } from './external';
|
||||
import { agentHandlers } from './agent';
|
||||
import { bytedeskHandlers } from './bytedesk';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -36,5 +37,6 @@ export const handlers = [
|
||||
...posthogHandlers,
|
||||
...externalHandlers,
|
||||
...agentHandlers,
|
||||
...bytedeskHandlers, // ⚡ Bytedesk 客服 Widget passthrough
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
@@ -188,46 +188,3 @@ export const paymentHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发支付成功
|
||||
* 使用方式:window.mockPaymentSuccess(orderId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockPaymentSuccess = (orderId) => {
|
||||
const order = mockOrders.get(orderId);
|
||||
if (!order) {
|
||||
console.error('[Mock Payment] 订单不存在:', orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
order.status = 'paid';
|
||||
order.paid_at = new Date().toISOString();
|
||||
console.log('[Mock Payment] ✅ 支付成功:', orderId);
|
||||
return true;
|
||||
};
|
||||
|
||||
window.getMockOrders = () => {
|
||||
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
|
||||
orderId: id,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
plan: `${order.plan_name} - ${order.billing_cycle}`,
|
||||
createdAt: new Date(order.created_at).toLocaleString()
|
||||
}));
|
||||
console.table(orders);
|
||||
return orders;
|
||||
};
|
||||
|
||||
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
|
||||
console.log(' window.getMockOrders() - 查看所有模拟订单');
|
||||
}
|
||||
|
||||
@@ -9,43 +9,44 @@ import React from 'react';
|
||||
*/
|
||||
export const lazyComponents = {
|
||||
// Home 模块
|
||||
HomePage: React.lazy(() => import('../views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
|
||||
ProfilePage: React.lazy(() => import('../views/Profile')),
|
||||
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
|
||||
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
|
||||
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
|
||||
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
|
||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
|
||||
ProfilePage: React.lazy(() => import('@views/Profile')),
|
||||
SettingsPage: React.lazy(() => import('@views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
|
||||
PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
|
||||
UserAgreement: React.lazy(() => import('@views/Pages/UserAgreement')),
|
||||
WechatCallback: React.lazy(() => import('@views/Pages/WechatCallback')),
|
||||
|
||||
// 社区/内容模块
|
||||
Community: React.lazy(() => import('../views/Community')),
|
||||
ConceptCenter: React.lazy(() => import('../views/Concept')),
|
||||
StockOverview: React.lazy(() => import('../views/StockOverview')),
|
||||
LimitAnalyse: React.lazy(() => import('../views/LimitAnalyse')),
|
||||
Community: React.lazy(() => import('@views/Community')),
|
||||
ConceptCenter: React.lazy(() => import('@views/Concept')),
|
||||
StockOverview: React.lazy(() => import('@views/StockOverview')),
|
||||
LimitAnalyse: React.lazy(() => import('@views/LimitAnalyse')),
|
||||
|
||||
// 交易模块
|
||||
TradingSimulation: React.lazy(() => import('../views/TradingSimulation')),
|
||||
TradingSimulation: React.lazy(() => import('@views/TradingSimulation')),
|
||||
|
||||
// 事件模块
|
||||
EventDetail: React.lazy(() => import('../views/EventDetail')),
|
||||
EventDetail: React.lazy(() => import('@views/EventDetail')),
|
||||
|
||||
// 公司相关模块
|
||||
CompanyIndex: React.lazy(() => import('../views/Company')),
|
||||
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
|
||||
CompanyIndex: React.lazy(() => import('@views/Company')),
|
||||
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||
AgentChat: React.lazy(() => import('@views/AgentChat')),
|
||||
|
||||
// 价值论坛模块
|
||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')),
|
||||
ValueForum: React.lazy(() => import('@views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('@views/ValueForum/PostDetail')),
|
||||
PredictionTopicDetail: React.lazy(() => import('@views/ValueForum/PredictionTopicDetail')),
|
||||
|
||||
// 数据浏览器模块
|
||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||
DataBrowser: React.lazy(() => import('@views/DataBrowser')),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,25 +10,12 @@ import { socketService } from '../socketService';
|
||||
export const socket = socketService;
|
||||
export { socketService };
|
||||
|
||||
// ⚡ 新增:暴露 Socket 实例到 window(用于调试和验证)
|
||||
// ⚡ 暴露 Socket 实例到 window(用于调试和验证)
|
||||
// 注意:移除首屏加载时的日志,避免阻塞感知
|
||||
if (typeof window !== 'undefined') {
|
||||
window.socket = socketService;
|
||||
window.socketService = socketService;
|
||||
|
||||
console.log(
|
||||
'%c[Socket Service] ✅ Socket instance exposed to window',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(' 📍 window.socket:', window.socket);
|
||||
console.log(' 📍 window.socketService:', window.socketService);
|
||||
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
|
||||
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
|
||||
// 日志已移除,如需调试可在控制台执行: console.log(window.socket)
|
||||
}
|
||||
|
||||
// 打印当前使用的服务类型
|
||||
console.log(
|
||||
'%c[Socket Service] Using REAL Socket Service',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -329,10 +329,18 @@ class SocketService {
|
||||
onSubscribed,
|
||||
} = options;
|
||||
|
||||
if (!this.socket || !this.connected) {
|
||||
logger.warn('socketService', 'Cannot subscribe: socket not connected');
|
||||
// 自动连接
|
||||
this.connect();
|
||||
// ⚡ 改进状态检查:同时检查 this.connected 和 socket.connected
|
||||
// 解决 connect 回调中 this.connected 尚未更新的竞争条件
|
||||
const isReady = this.socket && (this.socket.connected || this.connected);
|
||||
|
||||
if (!isReady) {
|
||||
logger.debug('socketService', 'Socket 尚未就绪,等待连接后订阅');
|
||||
|
||||
if (!this.socket) {
|
||||
// 自动连接
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// 等待连接成功后再订阅
|
||||
this.socket.once('connect', () => {
|
||||
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
// src/store/index.js
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||
import communityDataReducer from './slices/communityDataSlice';
|
||||
import posthogReducer from './slices/posthogSlice';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import posthogReducer from './slices/posthogSlice';
|
||||
// import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import industryReducer from './slices/industrySlice';
|
||||
import stockReducer from './slices/stockSlice';
|
||||
import authModalReducer from './slices/authModalSlice';
|
||||
import subscriptionReducer from './slices/subscriptionSlice';
|
||||
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||
|
||||
// ⚡ 基础 reducers(首屏必需)
|
||||
const staticReducers = {
|
||||
communityData: communityDataReducer,
|
||||
industry: industryReducer, // ✅ 行业分类数据管理
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
};
|
||||
|
||||
// ⚡ 动态 reducers 注册表
|
||||
const asyncReducers = {};
|
||||
|
||||
// ⚡ 创建根 reducer 的工厂函数
|
||||
const createRootReducer = () => combineReducers({
|
||||
...staticReducers,
|
||||
...asyncReducers,
|
||||
});
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
communityData: communityDataReducer,
|
||||
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
|
||||
industry: industryReducer, // ✅ 行业分类数据管理
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
},
|
||||
reducer: createRootReducer(),
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
@@ -28,14 +40,27 @@ export const store = configureStore({
|
||||
ignoredActions: [
|
||||
'communityData/fetchPopularKeywords/fulfilled',
|
||||
'communityData/fetchHotEvents/fulfilled',
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪(延迟加载后仍需)
|
||||
'stock/fetchEventStocks/fulfilled',
|
||||
'stock/fetchStockQuotes/fulfilled',
|
||||
],
|
||||
},
|
||||
})
|
||||
.concat(posthogMiddleware) // ✅ PostHog 自动追踪中间件
|
||||
// ⚡ PostHog 中间件延迟加载,首屏不再需要
|
||||
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
|
||||
});
|
||||
|
||||
/**
|
||||
* ⚡ 动态注入 reducer(用于延迟加载模块)
|
||||
* @param {string} key - reducer 的键名
|
||||
* @param {Function} reducer - reducer 函数
|
||||
*/
|
||||
export const injectReducer = (key, reducer) => {
|
||||
if (asyncReducers[key]) {
|
||||
return; // 已注入,避免重复
|
||||
}
|
||||
asyncReducers[key] = reducer;
|
||||
store.replaceReducer(createRootReducer());
|
||||
};
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -2,27 +2,81 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
/**
|
||||
* 检测当前设备是否为移动设备
|
||||
*
|
||||
* 判断逻辑:
|
||||
* 1. User Agent 检测(移动设备标识)
|
||||
* 2. 屏幕宽度检测(<= 768px)
|
||||
* 3. 触摸屏检测(支持触摸事件)
|
||||
*
|
||||
* @returns {boolean} true 表示移动设备,false 表示桌面设备
|
||||
* 设备类型枚举
|
||||
*/
|
||||
const detectIsMobile = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
const isMobileUA = mobileRegex.test(userAgent);
|
||||
const isMobileWidth = window.innerWidth <= 768;
|
||||
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
return isMobileUA || (isMobileWidth && hasTouchScreen);
|
||||
export const DeviceType = {
|
||||
MOBILE: 'mobile',
|
||||
TABLET: 'tablet',
|
||||
DESKTOP: 'desktop',
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测设备类型
|
||||
*
|
||||
* 判断逻辑:
|
||||
* - Mobile: 手机设备(iPhone、Android 手机等,宽度 <= 768px)
|
||||
* - Tablet: 平板设备(iPad、Android 平板等,宽度 769px - 1024px)
|
||||
* - Desktop: 桌面设备(PC、Mac 等,宽度 > 1024px)
|
||||
*
|
||||
* @returns {{ isMobile: boolean, isTablet: boolean, isDesktop: boolean, deviceType: string }}
|
||||
*/
|
||||
const detectDeviceType = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
const screenWidth = window.innerWidth;
|
||||
|
||||
// iPad 检测(包括 iPadOS 13+ 的 Safari,其 UA 不再包含 iPad)
|
||||
const isIPad =
|
||||
/iPad/i.test(userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
// Android 平板检测(Android 设备但 UA 不包含 Mobile)
|
||||
const isAndroidTablet = /Android/i.test(userAgent) && !/Mobile/i.test(userAgent);
|
||||
|
||||
// 手机 UA 检测(排除平板)
|
||||
const mobileRegex = /iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
const isAndroidPhone = /Android/i.test(userAgent) && /Mobile/i.test(userAgent);
|
||||
const isMobileUA = mobileRegex.test(userAgent) || isAndroidPhone;
|
||||
|
||||
// 触摸屏检测
|
||||
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
// 综合判断设备类型
|
||||
let isMobile = false;
|
||||
let isTablet = false;
|
||||
let isDesktop = false;
|
||||
|
||||
if (isIPad || isAndroidTablet) {
|
||||
// UA 明确是平板
|
||||
isTablet = true;
|
||||
} else if (isMobileUA) {
|
||||
// UA 明确是手机
|
||||
isMobile = true;
|
||||
} else if (screenWidth <= 768 && hasTouchScreen) {
|
||||
// 小屏幕 + 触摸屏 = 手机
|
||||
isMobile = true;
|
||||
} else if (screenWidth > 768 && screenWidth <= 1024 && hasTouchScreen) {
|
||||
// 中等屏幕 + 触摸屏 = 平板
|
||||
isTablet = true;
|
||||
} else {
|
||||
// 其他情况为桌面设备
|
||||
isDesktop = true;
|
||||
}
|
||||
|
||||
// 确定设备类型字符串
|
||||
let deviceType = DeviceType.DESKTOP;
|
||||
if (isMobile) deviceType = DeviceType.MOBILE;
|
||||
else if (isTablet) deviceType = DeviceType.TABLET;
|
||||
|
||||
return { isMobile, isTablet, isDesktop, deviceType };
|
||||
};
|
||||
|
||||
const initialDeviceState = detectDeviceType();
|
||||
|
||||
const initialState = {
|
||||
isMobile: detectIsMobile(),
|
||||
isMobile: initialDeviceState.isMobile,
|
||||
isTablet: initialDeviceState.isTablet,
|
||||
isDesktop: initialDeviceState.isDesktop,
|
||||
deviceType: initialDeviceState.deviceType,
|
||||
};
|
||||
|
||||
const deviceSlice = createSlice({
|
||||
@@ -37,7 +91,11 @@ const deviceSlice = createSlice({
|
||||
* - 屏幕方向变化时调用(orientationchange)
|
||||
*/
|
||||
updateScreenSize: (state) => {
|
||||
state.isMobile = detectIsMobile();
|
||||
const { isMobile, isTablet, isDesktop, deviceType } = detectDeviceType();
|
||||
state.isMobile = isMobile;
|
||||
state.isTablet = isTablet;
|
||||
state.isDesktop = isDesktop;
|
||||
state.deviceType = deviceType;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -47,6 +105,9 @@ export const { updateScreenSize } = deviceSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectIsMobile = (state) => state.device.isMobile;
|
||||
export const selectIsTablet = (state) => state.device.isTablet;
|
||||
export const selectIsDesktop = (state) => state.device.isDesktop;
|
||||
export const selectDeviceType = (state) => state.device.deviceType;
|
||||
|
||||
// Reducer
|
||||
export default deviceSlice.reducer;
|
||||
|
||||
@@ -7,26 +7,49 @@
|
||||
* 3. selector 函数测试
|
||||
*/
|
||||
|
||||
import deviceReducer, { updateScreenSize, selectIsMobile } from './deviceSlice';
|
||||
import deviceReducer, {
|
||||
updateScreenSize,
|
||||
selectIsMobile,
|
||||
selectIsTablet,
|
||||
selectIsDesktop,
|
||||
selectDeviceType,
|
||||
DeviceType,
|
||||
} from './deviceSlice';
|
||||
|
||||
describe('deviceSlice', () => {
|
||||
describe('reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
const initialState = deviceReducer(undefined, { type: '@@INIT' });
|
||||
expect(initialState).toHaveProperty('isMobile');
|
||||
expect(initialState).toHaveProperty('isTablet');
|
||||
expect(initialState).toHaveProperty('isDesktop');
|
||||
expect(initialState).toHaveProperty('deviceType');
|
||||
expect(typeof initialState.isMobile).toBe('boolean');
|
||||
expect(typeof initialState.isTablet).toBe('boolean');
|
||||
expect(typeof initialState.isDesktop).toBe('boolean');
|
||||
expect(typeof initialState.deviceType).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle updateScreenSize', () => {
|
||||
// 模拟初始状态
|
||||
const initialState = { isMobile: false };
|
||||
const initialState = {
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
deviceType: DeviceType.DESKTOP,
|
||||
};
|
||||
|
||||
// 执行 action(注意:实际 isMobile 值由 detectIsMobile() 决定)
|
||||
// 执行 action(注意:实际值由 detectDeviceType() 决定)
|
||||
const newState = deviceReducer(initialState, updateScreenSize());
|
||||
|
||||
// 验证状态结构
|
||||
expect(newState).toHaveProperty('isMobile');
|
||||
expect(newState).toHaveProperty('isTablet');
|
||||
expect(newState).toHaveProperty('isDesktop');
|
||||
expect(newState).toHaveProperty('deviceType');
|
||||
expect(typeof newState.isMobile).toBe('boolean');
|
||||
expect(typeof newState.isTablet).toBe('boolean');
|
||||
expect(typeof newState.isDesktop).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,22 +58,48 @@ describe('deviceSlice', () => {
|
||||
const mockState = {
|
||||
device: {
|
||||
isMobile: true,
|
||||
isTablet: false,
|
||||
isDesktop: false,
|
||||
deviceType: DeviceType.MOBILE,
|
||||
},
|
||||
};
|
||||
|
||||
const result = selectIsMobile(mockState);
|
||||
expect(result).toBe(true);
|
||||
expect(selectIsMobile(mockState)).toBe(true);
|
||||
expect(selectIsTablet(mockState)).toBe(false);
|
||||
expect(selectIsDesktop(mockState)).toBe(false);
|
||||
expect(selectDeviceType(mockState)).toBe(DeviceType.MOBILE);
|
||||
});
|
||||
|
||||
it('selectIsMobile should return false for desktop', () => {
|
||||
it('selectIsTablet should return correct value', () => {
|
||||
const mockState = {
|
||||
device: {
|
||||
isMobile: false,
|
||||
isTablet: true,
|
||||
isDesktop: false,
|
||||
deviceType: DeviceType.TABLET,
|
||||
},
|
||||
};
|
||||
|
||||
const result = selectIsMobile(mockState);
|
||||
expect(result).toBe(false);
|
||||
expect(selectIsMobile(mockState)).toBe(false);
|
||||
expect(selectIsTablet(mockState)).toBe(true);
|
||||
expect(selectIsDesktop(mockState)).toBe(false);
|
||||
expect(selectDeviceType(mockState)).toBe(DeviceType.TABLET);
|
||||
});
|
||||
|
||||
it('selectIsDesktop should return correct value', () => {
|
||||
const mockState = {
|
||||
device: {
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
deviceType: DeviceType.DESKTOP,
|
||||
},
|
||||
};
|
||||
|
||||
expect(selectIsMobile(mockState)).toBe(false);
|
||||
expect(selectIsTablet(mockState)).toBe(false);
|
||||
expect(selectIsDesktop(mockState)).toBe(true);
|
||||
expect(selectDeviceType(mockState)).toBe(DeviceType.DESKTOP);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,4 +109,12 @@ describe('deviceSlice', () => {
|
||||
expect(action.type).toBe('device/updateScreenSize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeviceType constants', () => {
|
||||
it('should have correct values', () => {
|
||||
expect(DeviceType.MOBILE).toBe('mobile');
|
||||
expect(DeviceType.TABLET).toBe('tablet');
|
||||
expect(DeviceType.DESKTOP).toBe('desktop');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* deviceSlice 使用示例
|
||||
*
|
||||
* 本文件展示如何在 React 组件中使用 deviceSlice 来实现响应式设计
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { selectIsMobile, updateScreenSize } from '@/store/slices/deviceSlice';
|
||||
import { Box, Text, VStack } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 示例 1: 基础使用 - 根据设备类型渲染不同内容
|
||||
*/
|
||||
export const BasicUsageExample = () => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isMobile ? (
|
||||
<Text>📱 移动端视图</Text>
|
||||
) : (
|
||||
<Text>💻 桌面端视图</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 示例 2: 监听窗口尺寸变化 - 动态更新设备状态
|
||||
*/
|
||||
export const ResizeListenerExample = () => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
// 监听窗口尺寸变化
|
||||
const handleResize = () => {
|
||||
dispatch(updateScreenSize());
|
||||
};
|
||||
|
||||
// 监听屏幕方向变化(移动设备)
|
||||
const handleOrientationChange = () => {
|
||||
dispatch(updateScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleOrientationChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleOrientationChange);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<Text>当前设备: {isMobile ? '移动设备' : '桌面设备'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
试试调整浏览器窗口大小
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 示例 3: 响应式布局 - 根据设备类型调整样式
|
||||
*/
|
||||
export const ResponsiveLayoutExample = () => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={isMobile ? 4 : 8}
|
||||
bg={isMobile ? 'blue.50' : 'gray.50'}
|
||||
borderRadius={isMobile ? 'md' : 'xl'}
|
||||
maxW={isMobile ? '100%' : '800px'}
|
||||
mx="auto"
|
||||
>
|
||||
<Text fontSize={isMobile ? 'md' : 'lg'}>
|
||||
响应式内容区域
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||
Padding: {isMobile ? '16px' : '32px'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 示例 4: 条件渲染组件 - 移动端显示简化版
|
||||
*/
|
||||
export const ConditionalRenderExample = () => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isMobile ? (
|
||||
// 移动端:简化版导航栏
|
||||
<Box bg="blue.500" p={2}>
|
||||
<Text color="white" fontSize="sm">☰ 菜单</Text>
|
||||
</Box>
|
||||
) : (
|
||||
// 桌面端:完整导航栏
|
||||
<Box bg="blue.500" p={4}>
|
||||
<Text color="white" fontSize="lg">
|
||||
首页 | 产品 | 关于我们 | 联系方式
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 示例 5: 在 App.js 中全局监听(推荐方式)
|
||||
*
|
||||
* 将以下代码添加到 src/App.js 中:
|
||||
*/
|
||||
export const AppLevelResizeListenerExample = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
dispatch(updateScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
// 初始化时也调用一次(可选)
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// 返回 null 或组件内容
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 示例 6: 自定义 Hook 封装(推荐)
|
||||
*
|
||||
* 在 src/hooks/useDevice.js 中创建自定义 Hook:
|
||||
*/
|
||||
// import { useSelector } from 'react-redux';
|
||||
// import { selectIsMobile } from '@/store/slices/deviceSlice';
|
||||
//
|
||||
// export const useDevice = () => {
|
||||
// const isMobile = useSelector(selectIsMobile);
|
||||
//
|
||||
// return {
|
||||
// isMobile,
|
||||
// isDesktop: !isMobile,
|
||||
// };
|
||||
// };
|
||||
|
||||
/**
|
||||
* 使用自定义 Hook:
|
||||
*/
|
||||
export const CustomHookUsageExample = () => {
|
||||
// const { isMobile, isDesktop } = useDevice();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* <Text>移动设备: {isMobile ? '是' : '否'}</Text> */}
|
||||
{/* <Text>桌面设备: {isDesktop ? '是' : '否'}</Text> */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 推荐实践:
|
||||
*
|
||||
* 1. 在 App.js 中添加全局 resize 监听器
|
||||
* 2. 创建自定义 Hook (useDevice) 简化使用
|
||||
* 3. 结合 Chakra UI 的响应式 Props(优先使用 Chakra 内置响应式)
|
||||
* 4. 仅在需要 JS 逻辑判断时使用 Redux(如条件渲染、动态导入)
|
||||
*
|
||||
* Chakra UI 响应式示例(推荐优先使用):
|
||||
* <Box
|
||||
* fontSize={{ base: 'sm', md: 'md', lg: 'lg' }} // Chakra 内置响应式
|
||||
* p={{ base: 4, md: 6, lg: 8 }}
|
||||
* >
|
||||
* 内容
|
||||
* </Box>
|
||||
*/
|
||||
@@ -12,6 +12,19 @@ import {
|
||||
} from '../../lib/posthog';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ⚡ 模块级缓存:存储 reducer 注入前的事件(避免丢失)
|
||||
let pendingEventsBeforeInjection = [];
|
||||
|
||||
/**
|
||||
* 获取并清空注入前缓存的事件
|
||||
* 在 App.js 中 reducer 注入后调用
|
||||
*/
|
||||
export const flushPendingEventsBeforeInjection = () => {
|
||||
const events = [...pendingEventsBeforeInjection];
|
||||
pendingEventsBeforeInjection = [];
|
||||
return events;
|
||||
};
|
||||
|
||||
// ==================== Initial State ====================
|
||||
|
||||
const initialState = {
|
||||
@@ -51,7 +64,15 @@ export const initializePostHog = createAsyncThunk(
|
||||
'posthog/initialize',
|
||||
async (_, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { config } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ 防御性检查:reducer 尚未注入
|
||||
if (!posthogState) {
|
||||
logger.warn('PostHog', 'PostHog reducer 尚未注入,跳过初始化');
|
||||
return { isInitialized: false, skipped: true };
|
||||
}
|
||||
|
||||
const { config } = posthogState;
|
||||
|
||||
if (!config.apiKey) {
|
||||
logger.warn('PostHog', '未配置 API Key,分析功能将被禁用');
|
||||
@@ -112,7 +133,20 @@ export const trackEvent = createAsyncThunk(
|
||||
'posthog/trackEvent',
|
||||
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { isInitialized } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ reducer 尚未注入:缓存到模块级队列(不丢弃)
|
||||
if (!posthogState) {
|
||||
logger.debug('PostHog', 'PostHog reducer 尚未注入,事件已缓存', { eventName });
|
||||
pendingEventsBeforeInjection.push({
|
||||
eventName,
|
||||
properties,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return { eventName, properties, pendingInjection: true };
|
||||
}
|
||||
|
||||
const { isInitialized } = posthogState;
|
||||
|
||||
if (!isInitialized) {
|
||||
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
|
||||
@@ -160,7 +194,14 @@ export const flushCachedEvents = createAsyncThunk(
|
||||
'posthog/flushCachedEvents',
|
||||
async (_, { getState, dispatch }) => {
|
||||
try {
|
||||
const { eventQueue, isInitialized } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ 防御性检查:reducer 尚未注入
|
||||
if (!posthogState) {
|
||||
return { flushed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const { eventQueue, isInitialized } = posthogState;
|
||||
|
||||
if (!isInitialized || eventQueue.length === 0) {
|
||||
return { flushed: 0 };
|
||||
@@ -281,15 +322,16 @@ export const {
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectPostHog = (state) => state.posthog;
|
||||
export const selectIsInitialized = (state) => state.posthog.isInitialized;
|
||||
export const selectUser = (state) => state.posthog.user;
|
||||
export const selectFeatureFlags = (state) => state.posthog.featureFlags;
|
||||
export const selectEventQueue = (state) => state.posthog.eventQueue;
|
||||
export const selectStats = (state) => state.posthog.stats;
|
||||
// ⚡ 安全的 selectors(支持 reducer 未注入的情况)
|
||||
export const selectPostHog = (state) => state.posthog || initialState;
|
||||
export const selectIsInitialized = (state) => state.posthog?.isInitialized ?? false;
|
||||
export const selectUser = (state) => state.posthog?.user ?? null;
|
||||
export const selectFeatureFlags = (state) => state.posthog?.featureFlags ?? {};
|
||||
export const selectEventQueue = (state) => state.posthog?.eventQueue ?? [];
|
||||
export const selectStats = (state) => state.posthog?.stats ?? initialState.stats;
|
||||
|
||||
export const selectFeatureFlag = (flagKey) => (state) => {
|
||||
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
return state.posthog?.featureFlags?.[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
};
|
||||
|
||||
export const selectIsOptedOut = () => posthogHasOptedOut();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 异步 Thunk: 获取用户订阅信息
|
||||
* 使用 condition 选项防止同一时刻多个组件重复发起请求
|
||||
*/
|
||||
export const fetchSubscriptionInfo = createAsyncThunk(
|
||||
'subscription/fetchInfo',
|
||||
@@ -51,6 +52,21 @@ export const fetchSubscriptionInfo = createAsyncThunk(
|
||||
logger.error('subscriptionSlice', '加载订阅信息失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
},
|
||||
{
|
||||
// 防止重复请求:如果已加载或正在加载中,则跳过本次请求
|
||||
condition: (_, { getState }) => {
|
||||
const { subscription } = getState();
|
||||
// 如果正在加载或已加载完成,返回 false 阻止请求
|
||||
if (subscription.loading || subscription.loaded) {
|
||||
logger.debug('subscriptionSlice', '跳过重复请求', {
|
||||
loading: subscription.loading,
|
||||
loaded: subscription.loaded
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -71,6 +87,7 @@ const subscriptionSlice = createSlice({
|
||||
},
|
||||
// 加载状态
|
||||
loading: false,
|
||||
loaded: false, // 是否已加载过(用于防止重复请求)
|
||||
error: null,
|
||||
// 订阅 Modal 状态
|
||||
isModalOpen: false,
|
||||
@@ -104,8 +121,8 @@ const subscriptionSlice = createSlice({
|
||||
end_date: null
|
||||
};
|
||||
state.loading = false;
|
||||
state.loaded = false; // 重置已加载标记,下次登录时重新获取
|
||||
state.error = null;
|
||||
logger.debug('subscriptionSlice', '重置订阅信息为免费版');
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -118,6 +135,7 @@ const subscriptionSlice = createSlice({
|
||||
// fetchSubscriptionInfo - fulfilled
|
||||
.addCase(fetchSubscriptionInfo.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true; // 标记已加载
|
||||
state.info = action.payload;
|
||||
state.error = null;
|
||||
})
|
||||
@@ -136,6 +154,7 @@ export const { openModal, closeModal, resetToFree } = subscriptionSlice.actions;
|
||||
// 导出 selectors
|
||||
export const selectSubscriptionInfo = (state) => state.subscription.info;
|
||||
export const selectSubscriptionLoading = (state) => state.subscription.loading;
|
||||
export const selectSubscriptionLoaded = (state) => state.subscription.loaded;
|
||||
export const selectSubscriptionError = (state) => state.subscription.error;
|
||||
export const selectSubscriptionModalOpen = (state) => state.subscription.isModalOpen;
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/* Tailwind CSS 入口文件 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 自定义工具类 */
|
||||
@layer utilities {
|
||||
/* 毛玻璃效果 */
|
||||
.backdrop-blur-xl {
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
}
|
||||
44
src/types/static-assets.d.ts
vendored
Normal file
44
src/types/static-assets.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 静态资源模块声明
|
||||
* 允许 TypeScript 正确处理静态资源导入
|
||||
*/
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.ico' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.bmp' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -58,11 +58,6 @@ const performanceMeasures: Array<{ name: string; duration: number; startMark: st
|
||||
*/
|
||||
class PerformanceMonitor {
|
||||
private metrics: PerformanceMetrics = {};
|
||||
private isProduction: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isProduction = process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记性能时间点
|
||||
@@ -70,12 +65,6 @@ class PerformanceMonitor {
|
||||
mark(name: string): void {
|
||||
const timestamp = performance.now();
|
||||
performanceMarks.set(name, timestamp);
|
||||
|
||||
if (!this.isProduction) {
|
||||
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
|
||||
time: `${timestamp.toFixed(2)}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +95,6 @@ class PerformanceMonitor {
|
||||
endMark
|
||||
});
|
||||
|
||||
if (!this.isProduction) {
|
||||
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
|
||||
duration: `${duration.toFixed(2)}ms`
|
||||
});
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { Dispatch, SetStateAction, KeyboardEvent } from 'react';
|
||||
import axios from 'axios';
|
||||
import { logger } from '@utils/logger';
|
||||
import { MessageTypes, type Message } from '../constants/messageTypes';
|
||||
import type { UploadedFile } from './useFileUpload';
|
||||
import type { User } from './useAgentSessions';
|
||||
@@ -221,7 +220,7 @@ export const useAgentChat = ({
|
||||
loadSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Agent chat error', error);
|
||||
console.error('Agent chat error:', error);
|
||||
|
||||
// 移除 "思考中" 和 "执行中" 消息
|
||||
setMessages((prev) =>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import axios from 'axios';
|
||||
import { logger } from '@utils/logger';
|
||||
import { MessageTypes, type Message } from '../constants/messageTypes';
|
||||
|
||||
/**
|
||||
@@ -103,7 +102,7 @@ export const useAgentSessions = ({
|
||||
setSessions(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载会话列表失败', error);
|
||||
console.error('加载会话列表失败:', error);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
@@ -135,7 +134,7 @@ export const useAgentSessions = ({
|
||||
setMessages(formattedMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载会话历史失败', error);
|
||||
console.error('加载会话历史失败:', error);
|
||||
}
|
||||
},
|
||||
[setMessages]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, lazy, Suspense } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
VStack,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
Skeleton,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 导入组件
|
||||
import DynamicNewsCard from './components/DynamicNewsCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import HeroPanel from './components/HeroPanel';
|
||||
// ⚡ HeroPanel 懒加载:包含 ECharts (~600KB),首屏不需要立即渲染
|
||||
const HeroPanel = lazy(() => import('./components/HeroPanel'));
|
||||
|
||||
// 导入自定义 Hooks
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
@@ -272,8 +274,14 @@ const Community = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 顶部说明面板:产品介绍 + 沪深指数 + 热门概念词云 */}
|
||||
<HeroPanel />
|
||||
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
|
||||
<Suspense fallback={
|
||||
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
|
||||
<Skeleton height="200px" borderRadius="lg" startColor="gray.800" endColor="gray.700" />
|
||||
</Box>
|
||||
}>
|
||||
<HeroPanel />
|
||||
</Suspense>
|
||||
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { DateClickArg } from '@fullcalendar/interaction';
|
||||
import { EventClickArg } from '@fullcalendar/common';
|
||||
import type { DateClickArg } from '@fullcalendar/interaction';
|
||||
import type { EventClickArg } from '@fullcalendar/core';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ export const PlansPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
|
||||
@@ -247,7 +247,7 @@ export const ReviewsPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
|
||||
@@ -78,14 +78,13 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
|
||||
},
|
||||
},
|
||||
candle: {
|
||||
type: 'line', // 使用折线图模式
|
||||
line: {
|
||||
upColor: themeColors.primary.gold,
|
||||
downColor: themeColors.primary.gold,
|
||||
style: 'solid',
|
||||
size: 2,
|
||||
type: 'area' as const, // 使用面积图模式
|
||||
area: {
|
||||
lineColor: themeColors.primary.gold,
|
||||
lineSize: 2,
|
||||
backgroundColor: [`${themeColors.primary.gold}30`, `${themeColors.primary.gold}05`],
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
crosshair: {
|
||||
horizontal: {
|
||||
line: {
|
||||
@@ -148,7 +147,7 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// 设置数据
|
||||
chart?.applyNewData(chartData);
|
||||
(chart as any)?.applyNewData(chartData);
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorMode,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { createChart, LineSeries } from 'lightweight-charts';
|
||||
import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
|
||||
import {
|
||||
FaExpand,
|
||||
FaCompress,
|
||||
FaCamera,
|
||||
FaRedo,
|
||||
FaCog,
|
||||
} from 'react-icons/fa';
|
||||
import { MetricDataPoint } from '@services/categoryService';
|
||||
|
||||
// 黑金主题配色
|
||||
const themeColors = {
|
||||
bg: {
|
||||
primary: '#0a0a0a',
|
||||
secondary: '#1a1a1a',
|
||||
card: '#1e1e1e',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b8b8b8',
|
||||
muted: '#808080',
|
||||
gold: '#D4AF37',
|
||||
},
|
||||
border: {
|
||||
default: 'rgba(255, 255, 255, 0.1)',
|
||||
gold: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
primary: {
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4E3A7',
|
||||
},
|
||||
};
|
||||
|
||||
interface TradingViewChartProps {
|
||||
data: MetricDataPoint[];
|
||||
metricName: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
|
||||
|
||||
const TradingViewChart: React.FC<TradingViewChartProps> = ({
|
||||
data,
|
||||
metricName,
|
||||
unit,
|
||||
frequency,
|
||||
}) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current || data.length === 0) return;
|
||||
|
||||
try {
|
||||
// 创建图表 (lightweight-charts 5.0 标准 API)
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: 500,
|
||||
layout: {
|
||||
background: { type: 'solid', color: themeColors.bg.card },
|
||||
textColor: themeColors.text.secondary,
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
horzLines: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: {
|
||||
color: themeColors.primary.gold,
|
||||
width: 1,
|
||||
style: 3, // 虚线
|
||||
labelBackgroundColor: themeColors.primary.gold,
|
||||
},
|
||||
horzLine: {
|
||||
color: themeColors.primary.gold,
|
||||
width: 1,
|
||||
style: 3,
|
||||
labelBackgroundColor: themeColors.primary.gold,
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: themeColors.border.default,
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: themeColors.border.default,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 12,
|
||||
barSpacing: 6, // 增加条形间距,减少拥挤
|
||||
fixLeftEdge: false,
|
||||
lockVisibleTimeRangeOnResize: true,
|
||||
rightBarStaysOnScroll: true,
|
||||
borderVisible: true,
|
||||
visible: true,
|
||||
// 控制时间标签的最小间距(像素)
|
||||
tickMarkMaxCharacterLength: 8,
|
||||
},
|
||||
localization: {
|
||||
locale: 'en-US',
|
||||
// 使用 ISO 日期格式,强制显示 YYYY-MM-DD
|
||||
dateFormat: 'dd MMM \'yy', // 这会被我们的自定义格式化器覆盖
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
},
|
||||
handleScale: {
|
||||
axisPressedMouseMove: true,
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 设置时间轴的自定义格式化器(强制显示 YYYY-MM-DD)
|
||||
chart.applyOptions({
|
||||
localization: {
|
||||
timeFormatter: (time) => {
|
||||
// time 可能是字符串 'YYYY-MM-DD' 或时间戳
|
||||
if (typeof time === 'string') {
|
||||
return time; // 直接返回 YYYY-MM-DD 字符串
|
||||
}
|
||||
|
||||
// 如果是时间戳,转换为 YYYY-MM-DD
|
||||
const date = new Date(time * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法)
|
||||
// 第一个参数是 series 类本身(不是实例)
|
||||
const lineSeries = chart.addSeries(LineSeries, {
|
||||
color: themeColors.primary.gold,
|
||||
lineWidth: 2,
|
||||
crosshairMarkerVisible: true,
|
||||
crosshairMarkerRadius: 6,
|
||||
crosshairMarkerBorderColor: themeColors.primary.goldLight,
|
||||
crosshairMarkerBackgroundColor: themeColors.primary.gold,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: true,
|
||||
priceLineColor: themeColors.primary.gold,
|
||||
priceLineWidth: 1,
|
||||
priceLineStyle: 3, // 虚线
|
||||
title: metricName,
|
||||
});
|
||||
|
||||
// 转换数据格式
|
||||
// lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time
|
||||
const chartData: LineData[] = data
|
||||
.filter((item) => item.value !== null)
|
||||
.map((item) => {
|
||||
// 确保日期格式为 YYYY-MM-DD
|
||||
const dateStr = item.date.trim();
|
||||
return {
|
||||
time: dateStr as Time,
|
||||
value: item.value as number,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 确保时间从左到右递增
|
||||
const timeA = new Date(a.time as string).getTime();
|
||||
const timeB = new Date(b.time as string).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
// 设置数据
|
||||
lineSeries.setData(chartData);
|
||||
|
||||
// 自动缩放到合适的视图
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
chartRef.current = chart;
|
||||
lineSeriesRef.current = lineSeries;
|
||||
|
||||
// 响应式调整
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chart) {
|
||||
chart.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart.remove();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ TradingView Chart 初始化失败:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
createChartType: typeof createChart,
|
||||
LineSeriesType: typeof LineSeries,
|
||||
});
|
||||
// 重新抛出错误让 ErrorBoundary 捕获
|
||||
throw error;
|
||||
}
|
||||
}, [data, metricName]);
|
||||
|
||||
// 时间范围筛选
|
||||
const handleTimeRangeChange = (range: TimeRange) => {
|
||||
setSelectedRange(range);
|
||||
|
||||
if (!chartRef.current || data.length === 0) return;
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (range) {
|
||||
case '1M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
case '3M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
break;
|
||||
case '6M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
|
||||
break;
|
||||
case '1Y':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'YTD':
|
||||
startDate = new Date(now.getFullYear(), 0, 1); // 当年1月1日
|
||||
break;
|
||||
case 'ALL':
|
||||
default:
|
||||
chartRef.current.timeScale().fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置可见范围
|
||||
const startTimestamp = startDate.getTime() / 1000;
|
||||
const endTimestamp = now.getTime() / 1000;
|
||||
|
||||
chartRef.current.timeScale().setVisibleRange({
|
||||
from: startTimestamp as Time,
|
||||
to: endTimestamp as Time,
|
||||
});
|
||||
};
|
||||
|
||||
// 重置缩放
|
||||
const handleReset = () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.timeScale().fitContent();
|
||||
setSelectedRange('ALL');
|
||||
}
|
||||
};
|
||||
|
||||
// 截图功能
|
||||
const handleScreenshot = () => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const canvas = chartContainerRef.current?.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
// 全屏切换
|
||||
const toggleFullscreen = () => {
|
||||
if (!chartContainerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (chartContainerRef.current.requestFullscreen) {
|
||||
chartContainerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 计算统计数据
|
||||
const stats = React.useMemo(() => {
|
||||
const values = data.filter((item) => item.value !== null).map((item) => item.value as number);
|
||||
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
const latest = values[values.length - 1];
|
||||
const first = values[0];
|
||||
const change = latest - first;
|
||||
const changePercent = first !== 0 ? (change / first) * 100 : 0;
|
||||
|
||||
return { min, max, avg, latest, change, changePercent };
|
||||
}, [data]);
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
if (Math.abs(num) >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B';
|
||||
}
|
||||
if (Math.abs(num) >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M';
|
||||
}
|
||||
if (Math.abs(num) >= 1e3) {
|
||||
return (num / 1e3).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4} w="100%">
|
||||
{/* 工具栏 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
|
||||
{/* 时间范围选择 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
onClick={() => handleTimeRangeChange(range)}
|
||||
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
|
||||
color={
|
||||
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
|
||||
}
|
||||
borderColor={themeColors.border.gold}
|
||||
_hover={{
|
||||
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
{range}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* 图表操作 */}
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="重置视图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Icon as={FaRedo} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="截图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleScreenshot}
|
||||
>
|
||||
<Icon as={FaCamera} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
<Icon as={isFullscreen ? FaCompress : FaExpand} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Flex
|
||||
justify="space-around"
|
||||
align="center"
|
||||
bg={themeColors.bg.secondary}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.default}
|
||||
wrap="wrap"
|
||||
gap={4}
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最新值
|
||||
</Text>
|
||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||||
{formatNumber(stats.latest)} {unit}
|
||||
</Text>
|
||||
<Text
|
||||
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.change >= 0 ? '+' : ''}
|
||||
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
平均值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.avg)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最高值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.max)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最低值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.min)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
数据点数
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{data.filter((item) => item.value !== null).length}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
频率
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{frequency}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box
|
||||
ref={chartContainerRef}
|
||||
w="100%"
|
||||
h="500px"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
bg={themeColors.bg.card}
|
||||
/>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
|
||||
<HStack spacing={4}>
|
||||
<Text>💡 提示:滚动鼠标滚轮缩放,拖拽移动视图</Text>
|
||||
</HStack>
|
||||
<Text>数据来源: {metricName}</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingViewChart;
|
||||
@@ -696,18 +696,20 @@ const DataBrowser: React.FC = () => {
|
||||
p={3}
|
||||
cursor="pointer"
|
||||
bg="transparent"
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
_hover={{
|
||||
bg: themeColors.bg.cardHover,
|
||||
borderLeftColor: themeColors.primary.gold,
|
||||
}}
|
||||
borderRadius="md"
|
||||
borderLeftWidth="3px"
|
||||
borderLeftColor="transparent"
|
||||
_hover={{ borderLeftColor: themeColors.primary.gold }}
|
||||
transition="all 0.2s"
|
||||
onClick={() => {
|
||||
// 转换搜索结果为 TreeMetric 格式
|
||||
const metric: TreeMetric = {
|
||||
metric_id: result.metric_id,
|
||||
metric_name: result.metric_name,
|
||||
source: result.source,
|
||||
source: result.source as 'SMM' | 'Mysteel',
|
||||
frequency: result.frequency,
|
||||
unit: result.unit,
|
||||
description: result.description,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
import { useHomeResponsive } from '@/hooks/useHomeResponsive';
|
||||
import { ACQUISITION_EVENTS } from '@/lib/constants';
|
||||
import { CORE_FEATURES } from '@/constants/homeFeatures';
|
||||
import { performanceMonitor } from '@/utils/performanceMonitor';
|
||||
import type { Feature } from '@/types/home';
|
||||
import { HeroBackground } from './components/HeroBackground';
|
||||
import { HeroHeader } from './components/HeroHeader';
|
||||
@@ -36,6 +37,11 @@ const HomePage: React.FC = () => {
|
||||
showDecorations
|
||||
} = useHomeResponsive();
|
||||
|
||||
// ⚡ 性能标记:首页组件挂载 = 渲染开始
|
||||
useEffect(() => {
|
||||
performanceMonitor.mark('homepage-render-start');
|
||||
}, []);
|
||||
|
||||
// PostHog 追踪:页面浏览
|
||||
useEffect(() => {
|
||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
||||
@@ -67,6 +73,8 @@ const HomePage: React.FC = () => {
|
||||
// 背景图片加载完成回调
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageLoaded(true);
|
||||
// ⚡ 性能标记:首页渲染完成(背景图片加载完成 = 首屏视觉完整)
|
||||
performanceMonitor.mark('homepage-render-end');
|
||||
}, []);
|
||||
|
||||
// 特色功能(第一个)
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* 首页骨架屏组件
|
||||
* 模拟首页的 6 个功能卡片布局,减少白屏感知时间
|
||||
*
|
||||
* 使用 Chakra UI 的 Skeleton 组件
|
||||
*
|
||||
* @module views/Home/components/HomePageSkeleton
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
VStack,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
interface HomePageSkeletonProps {
|
||||
/** 是否显示动画效果 */
|
||||
isAnimated?: boolean;
|
||||
/** 骨架屏速度(秒) */
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单个卡片骨架
|
||||
// ============================================================
|
||||
|
||||
const FeatureCardSkeleton: React.FC<{ isFeatured?: boolean }> = ({ isFeatured = false }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
p={isFeatured ? 8 : 6}
|
||||
h={isFeatured ? '350px' : '280px'}
|
||||
boxShadow={isFeatured ? 'xl' : 'md'}
|
||||
position="relative"
|
||||
>
|
||||
<VStack align="start" spacing={4} h="full">
|
||||
{/* 图标骨架 */}
|
||||
<Skeleton
|
||||
height={isFeatured ? '60px' : '48px'}
|
||||
width={isFeatured ? '60px' : '48px'}
|
||||
borderRadius="lg"
|
||||
startColor={isFeatured ? 'blue.100' : 'gray.100'}
|
||||
endColor={isFeatured ? 'blue.200' : 'gray.200'}
|
||||
/>
|
||||
|
||||
{/* 标题骨架 */}
|
||||
<Skeleton height="28px" width="70%" borderRadius="md" />
|
||||
|
||||
{/* 描述骨架 */}
|
||||
<SkeletonText
|
||||
mt="2"
|
||||
noOfLines={isFeatured ? 4 : 3}
|
||||
spacing="3"
|
||||
skeletonHeight="2"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
{/* 按钮骨架 */}
|
||||
<Skeleton
|
||||
height="40px"
|
||||
width={isFeatured ? '140px' : '100px'}
|
||||
borderRadius="md"
|
||||
mt="auto"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* Featured 徽章骨架 */}
|
||||
{isFeatured && (
|
||||
<Skeleton
|
||||
position="absolute"
|
||||
top="4"
|
||||
right="4"
|
||||
height="24px"
|
||||
width="80px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 主骨架组件
|
||||
// ============================================================
|
||||
|
||||
export const HomePageSkeleton: React.FC<HomePageSkeletonProps> = ({
|
||||
isAnimated = true,
|
||||
speed = 0.8,
|
||||
}) => {
|
||||
const containerBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
return (
|
||||
<Box
|
||||
w="full"
|
||||
minH="100vh"
|
||||
bg={containerBg}
|
||||
pt={{ base: '120px', md: '140px' }}
|
||||
pb={{ base: '60px', md: '80px' }}
|
||||
>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={{ base: 8, md: 12 }} align="stretch">
|
||||
{/* 顶部标题区域骨架 */}
|
||||
<VStack spacing={4} textAlign="center">
|
||||
{/* 主标题 */}
|
||||
<Skeleton
|
||||
height={{ base: '40px', md: '56px' }}
|
||||
width={{ base: '80%', md: '500px' }}
|
||||
borderRadius="md"
|
||||
speed={speed}
|
||||
/>
|
||||
|
||||
{/* 副标题 */}
|
||||
<Skeleton
|
||||
height={{ base: '20px', md: '24px' }}
|
||||
width={{ base: '90%', md: '600px' }}
|
||||
borderRadius="md"
|
||||
speed={speed}
|
||||
/>
|
||||
|
||||
{/* CTA 按钮 */}
|
||||
<HStack spacing={4} mt={4}>
|
||||
<Skeleton
|
||||
height="48px"
|
||||
width="140px"
|
||||
borderRadius="lg"
|
||||
speed={speed}
|
||||
/>
|
||||
<Skeleton
|
||||
height="48px"
|
||||
width="140px"
|
||||
borderRadius="lg"
|
||||
speed={speed}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 功能卡片网格骨架 */}
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
spacing={{ base: 6, md: 8 }}
|
||||
mt={8}
|
||||
>
|
||||
{/* 第一张卡片 - Featured (新闻中心) */}
|
||||
<Box gridColumn={{ base: 'span 1', lg: 'span 2' }}>
|
||||
<FeatureCardSkeleton isFeatured />
|
||||
</Box>
|
||||
|
||||
{/* 其余 5 张卡片 */}
|
||||
{[1, 2, 3, 4, 5].map((index) => (
|
||||
<FeatureCardSkeleton key={index} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 底部装饰元素骨架 */}
|
||||
<HStack justify="center" spacing={8} mt={12}>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<VStack key={index} spacing={2} align="center">
|
||||
<Skeleton
|
||||
height="40px"
|
||||
width="40px"
|
||||
borderRadius="full"
|
||||
speed={speed}
|
||||
/>
|
||||
<Skeleton height="16px" width="60px" borderRadius="md" speed={speed} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default HomePageSkeleton;
|
||||
@@ -37,20 +37,40 @@ export default function WechatCallback() {
|
||||
// 1. 获取URL参数
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const wechatLogin = searchParams.get("wechat_login");
|
||||
|
||||
// 2. 参数验证
|
||||
// 2. 检查是否是 H5 模式登录成功回调
|
||||
// 后端已经完成登录,只需要刷新前端 session 状态
|
||||
if (wechatLogin === "success") {
|
||||
logger.info('WechatCallback', 'H5 模式登录成功', { state });
|
||||
|
||||
// 刷新 session 状态
|
||||
await checkSession();
|
||||
|
||||
// 显示成功状态
|
||||
setStatus("success");
|
||||
setMessage("登录成功!正在跳转...");
|
||||
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
navigate("/home", { replace: true });
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 原有的 code 模式处理(微信直接回调前端的情况)
|
||||
if (!code) {
|
||||
throw new Error("授权失败:缺少授权码");
|
||||
}
|
||||
|
||||
// 3. 调用后端处理回调
|
||||
// 4. 调用后端处理回调
|
||||
const response = await authService.handleWechatH5Callback(code, state);
|
||||
|
||||
if (!response || !response.success) {
|
||||
throw new Error(response?.error || "授权失败,请重试");
|
||||
}
|
||||
|
||||
// 4. 存储用户信息(如果有返回token)
|
||||
// 5. 存储用户信息(如果有返回token)
|
||||
if (response.token) {
|
||||
localStorage.setItem("token", response.token);
|
||||
}
|
||||
@@ -58,14 +78,14 @@ export default function WechatCallback() {
|
||||
localStorage.setItem("user", JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
// 5. 更新session
|
||||
// 6. 更新session
|
||||
await checkSession();
|
||||
|
||||
// 6. 显示成功状态
|
||||
// 7. 显示成功状态
|
||||
setStatus("success");
|
||||
setMessage("登录成功!正在跳转...");
|
||||
|
||||
// 7. 延迟跳转到首页
|
||||
// 8. 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
navigate("/home", { replace: true });
|
||||
}, 1500);
|
||||
@@ -73,6 +93,7 @@ export default function WechatCallback() {
|
||||
logger.error('WechatCallback', 'handleCallback', error, {
|
||||
code: searchParams.get("code"),
|
||||
state: searchParams.get("state"),
|
||||
wechat_login: searchParams.get("wechat_login"),
|
||||
errorMessage: error.message
|
||||
});
|
||||
setStatus("error");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Button from "@/components/Button2";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
import { pricing } from "./content";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user