diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts index 9f7821a..6a6a5ef 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -1511,35 +1511,35 @@ analyticsRouter.get('/project/:id/conversion-funnel', async (c) => { .eq('id', projectId) .single(); - // // 如果找不到项目或发生错误,返回模拟数据 - // if (projectError) { - // console.log(`项目未找到或数据库错误,返回模拟数据。项目ID: ${projectId}, 错误: ${projectError.message}`); + // 如果找不到项目或发生错误,返回模拟数据 + if (projectError) { + console.log(`项目未找到或数据库错误,返回模拟数据。项目ID: ${projectId}, 错误: ${projectError.message}`); - // // 生成模拟的漏斗数据 - // const mockFunnelData = [ - // { stage: 'Awareness', count: 100, rate: 100 }, - // { stage: 'Interest', count: 75, rate: 75 }, - // { stage: 'Consideration', count: 50, rate: 50 }, - // { stage: 'Intent', count: 30, rate: 30 }, - // { stage: 'Evaluation', count: 20, rate: 20 }, - // { stage: 'Purchase', count: 10, rate: 10 } - // ]; + // 生成模拟的漏斗数据 + const mockFunnelData = [ + { stage: 'Awareness', count: 100, rate: 100 }, + { stage: 'Interest', count: 75, rate: 75 }, + { stage: 'Consideration', count: 50, rate: 50 }, + { stage: 'Intent', count: 30, rate: 30 }, + { stage: 'Evaluation', count: 20, rate: 20 }, + { stage: 'Purchase', count: 10, rate: 10 } + ]; - // return c.json({ - // project: { - // id: projectId, - // name: `模拟项目 (ID: ${projectId})` - // }, - // timeframe, - // funnel_data: mockFunnelData, - // metrics: { - // total_influencers: 100, - // conversion_rate: 10, - // avg_stage_dropoff: 18 - // }, - // is_mock_data: true - // }); - // } + return c.json({ + project: { + id: projectId, + name: `模拟项目 (ID: ${projectId})` + }, + timeframe, + funnel_data: mockFunnelData, + metrics: { + total_influencers: 100, + conversion_rate: 10, + avg_stage_dropoff: 18 + }, + is_mock_data: true + }); + } // 获取项目关联的网红及其详细信息 const { data: projectInfluencers, error: influencersError } = await supabase diff --git a/web/src/App.tsx b/web/src/App.tsx index c961594..d8c925a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -53,7 +53,7 @@ const AppContent = () => { // Render main app layout when authenticated const renderAppLayout = () => { return ( -
+
{ @@ -63,17 +63,19 @@ const AppContent = () => { isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> -
+
setSidebarOpen(!sidebarOpen)} /> - - } /> - } /> - } /> - } /> - } /> - +
+ + } /> + } /> + } /> + } /> + } /> + +
); diff --git a/web/src/components/PostList.tsx b/web/src/components/PostList.tsx index 408d528..721b90b 100644 --- a/web/src/components/PostList.tsx +++ b/web/src/components/PostList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, @@ -11,7 +11,9 @@ import { Typography, Select, Drawer, - Form + Form, + Empty, + Spin } from 'antd'; import { UserOutlined, @@ -26,6 +28,7 @@ import { MessageOutlined } from '@ant-design/icons'; import { format } from 'date-fns'; +import supabase from '../utils/supabase'; // API response type definition based on backend structure interface ApiPost { @@ -87,64 +90,95 @@ const PostList: React.FC = ({ influencerId, projectId }) => { const [contentTypeFilter, setContentTypeFilter] = useState('all'); const [showFilters, setShowFilters] = useState(false); const [totalPosts, setTotalPosts] = useState(0); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const observer = useRef(null); + const loadMoreRef = useRef(null); // Fetch posts data const fetchPosts = async () => { + setLoading(true); + setError(null); + setPosts([]); + setOffset(0); + setHasMore(true); + try { - setLoading(true); - setError(null); + // Build query parameters + const params = new URLSearchParams(); + const pageSize = 20; + params.append('limit', pageSize.toString()); + params.append('offset', '0'); + params.append('sort', 'published_at'); + params.append('order', 'desc'); - // 构建查询参数 - const queryParams = new URLSearchParams(); + if (platformFilter && platformFilter !== 'all') { + params.append('platform', platformFilter); + } - // 添加过滤条件 if (influencerId) { - queryParams.append('influencer_id', influencerId); + params.append('influencer_id', influencerId); } if (projectId) { - queryParams.append('project_id', projectId); + params.append('project_id', projectId); } - if (platformFilter !== 'all') { - queryParams.append('platform', platformFilter); + // Get auth token from Supabase + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + + if (!token) { + console.error('Authentication token not found'); + setError('Authentication required. Please log in again.'); + setLoading(false); + // Fall back to mock data if no token + const mockData = getMockPosts(); + setPosts(mockData); + setTotalPosts(mockData.length); + setHasMore(false); + return; } - // 添加分页和排序 - queryParams.append('limit', '20'); - queryParams.append('offset', '0'); - queryParams.append('sort', 'published_at'); - queryParams.append('order', 'desc'); - - // 添加认证头 - const authToken = 'eyJhbGciOiJIUzI1NiIsImtpZCI6Inl3blNGYnRBOGtBUnl4UmUiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3h0cWhsdXpvcm5hemxta29udWNyLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1YjQzMThiZi0yMWE4LTQ3YWMtOGJmYS0yYThmOGVmOWMwZmIiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzQxNjI3ODkyLCJpYXQiOjE3NDE2MjQyOTIsImVtYWlsIjoidml0YWxpdHltYWlsZ0BnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7ImVtYWlsX3ZlcmlmaWVkIjp0cnVlfSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc0MTYyNDI5Mn1dLCJzZXNzaW9uX2lkIjoiODlmYjg0YzktZmEzYy00YmVlLTk0MDQtNjI1MjE0OGIyMzVlIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.VuUX2yhqN-FZseKL8fQG91i1cohfRqW2m1Z8CIWhZuk'; - - // 发送API请求 - const response = await fetch(`http://localhost:4000/api/posts?${queryParams.toString()}`, { + // Make API request + const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, { + method: 'GET', headers: { - 'accept': 'application/json', - 'Authorization': `Bearer ${authToken}` + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` } }); + if (response.status === 401) { + console.error('Authentication failed: Unauthorized'); + setError('Your session has expired. Please log in again.'); + // Fall back to mock data on auth error + const mockData = getMockPosts(); + setPosts(mockData); + setTotalPosts(mockData.length); + setHasMore(false); + setLoading(false); + return; + } + if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); + throw new Error(`API request failed with status: ${response.status}`); } const data = await response.json(); - console.log('API返回的posts数据:', data); - if (data && data.posts && Array.isArray(data.posts)) { - // 转换API返回的数据为前端需要的格式 + if (data && Array.isArray(data.posts)) { + // Transform API posts to frontend format const frontendPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({ id: apiPost.post_id, title: apiPost.title || 'Untitled Post', description: apiPost.description || '', author: apiPost.influencer?.name || 'Unknown Author', - authorType: 'influencer', // 默认为influencer类型 - platform: (apiPost.platform?.toLowerCase() || 'other') as any, + authorType: 'influencer', // Default to influencer + platform: apiPost.platform as FrontendPost['platform'], // Cast to expected platform type contentType: determineContentType(apiPost), - timestamp: apiPost.published_at || apiPost.updated_at || new Date().toISOString(), + timestamp: apiPost.published_at, engagement: { views: apiPost.views_count, likes: apiPost.likes_count, @@ -154,21 +188,27 @@ const PostList: React.FC = ({ influencerId, projectId }) => { url: apiPost.post_url })); - setTotalPosts(data.total || frontendPosts.length); setPosts(frontendPosts); + setTotalPosts(data.total || frontendPosts.length); + setOffset(frontendPosts.length); + setHasMore(frontendPosts.length === pageSize); } else { - console.warn('API返回的数据格式不符合预期'); - setError('Failed to load posts data'); - // 使用模拟数据作为后备 - useMockData(); + console.warn('Invalid data format received from API, using mock data'); + // Fall back to mock data if API response format is unexpected + const mockData = getMockPosts(); + setPosts(mockData); + setTotalPosts(mockData.length); + setHasMore(false); } - - setLoading(false); - } catch (error) { - console.error('Error fetching posts:', error); - setError('Failed to fetch posts. Please try again later.'); - // 使用模拟数据作为后备 - useMockData(); + } catch (err) { + console.error('Error fetching posts:', err); + setError('Failed to fetch posts. Using mock data instead.'); + // Fall back to mock data on error + const mockData = getMockPosts(); + setPosts(mockData); + setTotalPosts(mockData.length); + setHasMore(false); + } finally { setLoading(false); } }; @@ -283,77 +323,207 @@ const PostList: React.FC = ({ influencerId, projectId }) => { return 'post'; }; - // 使用模拟数据作为后备 - const useMockData = () => { - const mockPosts: FrontendPost[] = [ + // Helper function to get mock posts (extracted from useMockData) + const getMockPosts = (): FrontendPost[] => { + return [ { id: '1', - title: 'Introduction to React Hooks', - description: 'React Hooks are a powerful feature that allows you to use state and other React features without writing a class.', - author: 'John Smith', + title: 'How to Get Started with React', + description: 'A beginner-friendly guide to React development', + author: 'Jane Smith', authorType: 'influencer', platform: 'youtube', contentType: 'video', - timestamp: '2023-05-15T10:30:00Z', + timestamp: '2023-05-15T14:30:00Z', engagement: { - views: 1500, - likes: 85, - comments: 12, - shares: 5 + views: 15000, + likes: 1200, + comments: 85, + shares: 320 }, - url: 'https://youtube.com/watch?v=example1' + url: 'https://youtube.com/watch?v=abc123' }, - { - id: '2', - title: 'Advanced CSS Techniques', - description: 'Learn about the latest CSS techniques including Grid, Flexbox, and CSS Variables.', - author: 'Sarah Johnson', - authorType: 'influencer', - platform: 'instagram', - contentType: 'post', - timestamp: '2023-05-14T14:20:00Z', - engagement: { - views: 980, - likes: 72, - comments: 8, - shares: 3 - }, - url: 'https://instagram.com/p/example2' - }, - { - id: '3', - title: 'JavaScript Performance Tips', - description: 'Optimize your JavaScript code with these performance tips and best practices.', - author: 'Michael Brown', - authorType: 'influencer', - platform: 'linkedin', - contentType: 'post', - timestamp: '2023-05-13T09:15:00Z', - engagement: { - views: 750, - likes: 68, - comments: 15, - shares: 10 - }, - url: 'https://linkedin.com/post/example3' - } + // ... add more mock posts as needed ]; - - setTotalPosts(mockPosts.length); - setPosts(mockPosts); }; + // 加载更多数据的函数 + const loadMorePosts = useCallback(async () => { + if (isLoadingMore || !hasMore) return; + + console.log('Loading more posts from offset:', offset); + setIsLoadingMore(true); + try { + // 构建查询参数 + const params = new URLSearchParams(); + const pageSize = 20; + params.append('limit', pageSize.toString()); + params.append('offset', offset.toString()); + params.append('sort', 'published_at'); + params.append('order', 'desc'); + + if (platformFilter && platformFilter !== 'all') { + params.append('platform', platformFilter); + } + + if (influencerId) { + params.append('influencer_id', influencerId); + } + + if (projectId) { + params.append('project_id', projectId); + } + + // 获取认证token + const token = await getAuthToken(); + + if (!token) { + console.error('Authentication token not found'); + setError('Authentication required. Please log in again.'); + setIsLoadingMore(false); + return; + } + + console.log('Fetching more posts with params:', params.toString()); + + // 发送API请求 + const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + if (response.status === 401) { + console.error('Authentication failed: Unauthorized'); + setError('Your session has expired. Please log in again.'); + setIsLoadingMore(false); + return; + } + + if (!response.ok) { + throw new Error(`API request failed with status: ${response.status}`); + } + + const data = await response.json(); + console.log('Received more posts:', data?.posts?.length || 0); + + if (data && Array.isArray(data.posts)) { + // 转换API返回的数据为前端需要的格式 + const newPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({ + id: apiPost.post_id, + title: apiPost.title || 'Untitled Post', + description: apiPost.description || '', + author: apiPost.influencer?.name || 'Unknown Author', + authorType: 'influencer', // 默认为influencer类型 + platform: apiPost.platform as FrontendPost['platform'], + contentType: determineContentType(apiPost), + timestamp: apiPost.published_at, + engagement: { + views: apiPost.views_count, + likes: apiPost.likes_count, + comments: apiPost.comments_count, + shares: apiPost.shares_count + }, + url: apiPost.post_url + })); + + // 更新状态 + setPosts(prevPosts => [...prevPosts, ...newPosts]); + setTotalPosts(data.total || totalPosts); + setOffset(prevOffset => prevOffset + newPosts.length); + + // 检查是否还有更多数据 + setHasMore(newPosts.length === pageSize); + + console.log('Updated posts count:', posts.length + newPosts.length); + } else { + // 如果没有更多数据 + console.log('No more posts available'); + setHasMore(false); + } + } catch (err) { + console.error('Error fetching more posts:', err); + setError('Failed to fetch more posts.'); + } finally { + setIsLoadingMore(false); + } + }, [offset, platformFilter, influencerId, projectId, isLoadingMore, hasMore, totalPosts, posts.length]); + + // 设置Intersection Observer来监测滚动 + useEffect(() => { + if (loading) return; + + if (observer.current) { + observer.current.disconnect(); + } + + observer.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + console.log('Intersection observed, loading more posts...'); + loadMorePosts(); + } + }, { + rootMargin: '100px', // 提前100px触发加载 + threshold: 0.1 // 降低阈值,使其更容易触发 + }); + + if (loadMoreRef.current) { + observer.current.observe(loadMoreRef.current); + } + + return () => { + if (observer.current) { + observer.current.disconnect(); + } + }; + }, [loading, hasMore, isLoadingMore, loadMorePosts]); + // 在组件加载时获取数据 useEffect(() => { + console.log('Fetching initial posts data...'); fetchPosts(); + + // 组件卸载时清理 + return () => { + if (observer.current) { + observer.current.disconnect(); + } + }; }, [influencerId, projectId, platformFilter]); + // 获取Supabase会话和token的函数 + const getAuthToken = async () => { + try { + // 尝试从localStorage中查找Supabase token + // Supabase通常将token存储在以sb-开头的键中 + const keys = Object.keys(localStorage); + const supabaseTokenKey = keys.find(key => key.startsWith('sb-') && localStorage.getItem(key)?.includes('access_token')); + + if (supabaseTokenKey) { + try { + const supabaseData = JSON.parse(localStorage.getItem(supabaseTokenKey) || '{}'); + return supabaseData.access_token; + } catch (e) { + console.error('Error parsing Supabase token:', e); + } + } + + // 如果没有找到Supabase token,尝试使用常规token + return localStorage.getItem('token'); + } catch (error) { + console.error('Error getting auth token:', error); + return null; + } + }; + if (error) { return
{error}
; } return ( -
+
= ({ influencerId, projectId }) => { } loading={loading} + className="mb-4" > ({ onClick: () => handleViewComments(post.id), style: { cursor: 'pointer' } @@ -485,7 +656,32 @@ const PostList: React.FC = ({ influencerId, projectId }) => { ), }, ]} + scroll={{ x: 'max-content' }} /> + + {/* 无限滚动加载指示器 */} +
+ {isLoadingMore && ( +
+ +
+ )} + {!hasMore && posts.length > 0 && ( +
+ No more posts to load +
+ )} + {!hasMore && posts.length === 0 && !loading && ( + + )} +
{/* Filters Drawer */}