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 */}