From 6940d60510de65bd268f95a78b5b5946161a52e7 Mon Sep 17 00:00:00 2001 From: William Tso Date: Tue, 8 Apr 2025 07:46:20 +0800 Subject: [PATCH] time chart int --- app/analytics/page.tsx | 198 ++++++++++++++++++++-- app/api/events/summary/route.ts | 2 + app/api/shortlinks/[id]/route.ts | 140 +++++++++++++++ app/api/shortlinks/byUrl/route.ts | 3 + app/api/shortlinks/exact/route.ts | 142 ++++++++++++++++ app/components/charts/TimeSeriesChart.tsx | 9 +- app/links/page.tsx | 85 +++++++--- app/utils/store.ts | 1 + 8 files changed, 541 insertions(+), 39 deletions(-) create mode 100644 app/api/shortlinks/[id]/route.ts create mode 100644 app/api/shortlinks/exact/route.ts diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index c767beb..692c209 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -175,9 +175,46 @@ export default function AnalyticsPage() { // 如果 localStorage 中没有匹配的数据,则从 API 获取 const fetchShortUrlData = async () => { try { - // 构建带有 URL 参数的查询字符串 - const encodedUrl = encodeURIComponent(shorturlParam); - const apiUrl = `/api/shortlinks/byUrl?url=${encodedUrl}`; + let apiUrl = ''; + + // Check if shorturlParam is a URL or an ID + if (shorturlParam.startsWith('http')) { + // Direct match by shortUrl is more reliable than URL parsing + const exactApiUrl = `/api/shortlinks/exact?shortUrl=${encodeURIComponent(shorturlParam)}`; + console.log('Fetching shorturl by exact match:', exactApiUrl); + + // Try the exact endpoint first + const response = await fetch(exactApiUrl); + + if (response.ok) { + const result = await response.json(); + + if (result.success && result.data) { + console.log('Found shortlink by exact shortUrl match:', result.data); + console.log('External ID from exact API:', result.data.externalId); + + if (result.data.externalId) { + // Save to sessionStorage for immediate use + sessionStorage.setItem('current_shorturl_external_id', result.data.externalId); + console.log('Saved external ID to sessionStorage:', result.data.externalId); + } + + // Set in store and trigger data fetch + setSelectedShortUrl(result.data); + setTimeout(() => { + setShouldFetchData(true); + }, 100); + return; + } + } + + // Fallback to old method if exact match fails + console.log('Exact match failed, trying byUrl endpoint'); + apiUrl = `/api/shortlinks/byUrl?url=${encodeURIComponent(shorturlParam)}`; + } else { + // It might be an ID or slug, try the ID endpoint directly + apiUrl = `/api/shortlinks/${shorturlParam}`; + } console.log('Fetching shorturl data from:', apiUrl); @@ -186,7 +223,7 @@ export default function AnalyticsPage() { if (!response.ok) { console.error('Failed to fetch shorturl data:', response.statusText); - // Trigger data fetching even if shortURL data fetch failed + // Still trigger data fetching to show all data instead setShouldFetchData(true); return; } @@ -196,12 +233,28 @@ export default function AnalyticsPage() { // 如果找到匹配的短链接数据 if (result.success && result.data) { console.log('Retrieved shortlink data:', result.data); + // Log the external ID explicitly for debugging + console.log('External ID from API:', result.data.externalId); + // 设置到 Zustand store (会自动更新到 localStorage) setSelectedShortUrl(result.data); + + // 强制保证 externalId 被设置到 params + const savedExternalId = result.data.externalId; + if (savedExternalId) { + // Save to sessionStorage for immediate use + sessionStorage.setItem('current_shorturl_external_id', savedExternalId); + console.log('Saved external ID to sessionStorage:', savedExternalId); + } + + // Explicitly wait for the state update to be applied + // before triggering the data fetching + setTimeout(() => { + setShouldFetchData(true); + }, 100); + } else { + setShouldFetchData(true); } - - // Trigger data fetching after shortURL data is processed - setShouldFetchData(true); } catch (error) { console.error('Error fetching shorturl data:', error); // Trigger data fetching even if there was an error @@ -275,10 +328,33 @@ export default function AnalyticsPage() { pageSize: pageSize.toString() }); - // Add linkId parameter if a shorturl is selected - if (selectedShortUrl && selectedShortUrl.id) { - params.append('linkId', selectedShortUrl.id); - console.log('Adding linkId to requests:', selectedShortUrl.id); + // Verify the shortUrl data is loaded and the externalId exists + // before adding the linkId parameter + if (selectedShortUrl) { + console.log('Current selectedShortUrl data:', selectedShortUrl); + + if (selectedShortUrl.externalId) { + params.append('linkId', selectedShortUrl.externalId); + console.log('Adding linkId (externalId) to requests:', selectedShortUrl.externalId); + } else { + // Try to get externalId from sessionStorage as backup + const savedExternalId = sessionStorage.getItem('current_shorturl_external_id'); + if (savedExternalId) { + params.append('linkId', savedExternalId); + console.log('Adding linkId from sessionStorage:', savedExternalId); + } else { + // External ID is missing - this will result in no data being returned + console.warn('WARNING: externalId is missing in the shortUrl data - no results will be returned!', selectedShortUrl); + } + } + // We now know the events table exclusively uses external_id format, so never fall back to id + + // Add an extra log to debug the issue + console.log('Complete shorturl data:', JSON.stringify({ + id: selectedShortUrl.id, + externalId: selectedShortUrl.externalId, + shortUrl: selectedShortUrl.shortUrl + })); } // 添加团队ID参数 - 支持多个团队 @@ -302,15 +378,38 @@ export default function AnalyticsPage() { }); } + // 记录构建的 URL,以确保参数正确包含 + const summaryUrl = `${baseUrl}/summary?${params.toString()}`; + const timeSeriesUrl = `${baseUrl}/time-series?${params.toString()}`; + const geoUrl = `${baseUrl}/geo?${params.toString()}`; + const devicesUrl = `${baseUrl}/devices?${params.toString()}`; + const eventsUrl = `${baseUrl}?${params.toString()}`; + + console.log('Final API URLs being called:'); + console.log('- Summary API:', summaryUrl); + console.log('- TimeSeries API:', timeSeriesUrl); + console.log(`- Params contain linkId? ${params.has('linkId')}`); + console.log(`- All params: ${params.toString()}`); + // 并行获取所有数据 const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([ - fetch(`${baseUrl}/summary?${params.toString()}`), - fetch(`${baseUrl}/time-series?${params.toString()}`), - fetch(`${baseUrl}/geo?${params.toString()}`), - fetch(`${baseUrl}/devices?${params.toString()}`), - fetch(`${baseUrl}?${params.toString()}`) + fetch(summaryUrl), + fetch(timeSeriesUrl), + fetch(geoUrl), + fetch(devicesUrl), + fetch(eventsUrl) ]); + // 添加额外日志,记录完整的 URL 请求 + console.log('Summary API URL:', summaryUrl); + + if (selectedShortUrl?.externalId) { + console.log('Verifying linkId is in params:', + `linkId=${selectedShortUrl.externalId}`, + `included: ${params.toString().includes(`linkId=${selectedShortUrl.externalId}`)}` + ); + } + const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([ summaryRes.json(), timeSeriesRes.json(), @@ -402,6 +501,68 @@ export default function AnalyticsPage() { )} + {/* Debug info - remove in production */} + {process.env.NODE_ENV !== 'production' && ( +
+

Debug Info:

+
+ Hydrated: {isHydrated ? 'Yes' : 'No'} | + Should Fetch: {shouldFetchData ? 'Yes' : 'No'} | + Has ShortUrl: {selectedShortUrl ? 'Yes' : 'No'} +
+ {selectedShortUrl && ( +
+ ShortUrl ID: {selectedShortUrl.id} | + ExternalId: {selectedShortUrl.externalId || 'MISSING'} | + URL: {selectedShortUrl.shortUrl} +
+ )} +
+ IMPORTANT: + The events table uses external_id as link_id, not the UUID format. + External ID format sample: cm8x34sdr0007m11yh1xe6qc2 +
+ + {/* Full link data for debugging */} + {selectedShortUrl && ( +
+
+ Show Full Link Data +
+ {JSON.stringify(selectedShortUrl, null, 2)} +
+
+
+ )} + + {/* URL Parameters */} +
+
+ API Request URLs +
+
Summary API URL: {`/api/events/summary?${new URLSearchParams({ + startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"), + endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"), + ...(selectedShortUrl?.externalId ? { linkId: selectedShortUrl.externalId } : {}) + }).toString()}`}
+
+
+
+ + {/* Local Storage Data */} +
+
+ LocalStorage Data +
+ {typeof window !== 'undefined' && localStorage.getItem('shorturl-storage') ? + JSON.stringify(JSON.parse(localStorage.getItem('shorturl-storage') || '{}'), null, 2) : + 'No localStorage data'} +
+
+
+
+ )} +

Analytics Dashboard

@@ -424,7 +585,8 @@ export default function AnalyticsPage() {
Analytics filtered for this short URL only - {selectedShortUrl.id && (ID: {selectedShortUrl.id})} + {selectedShortUrl.id && (ID: {selectedShortUrl.id.substring(0, 8)}...)} + {selectedShortUrl.externalId && (External ID: {selectedShortUrl.externalId})}
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
@@ -727,7 +889,7 @@ export default function AnalyticsPage() { {info.linkName}
- ID: {event.link_id?.substring(0, 8) || '-'} + ID: {event.link_id || '-'}
diff --git a/app/api/events/summary/route.ts b/app/api/events/summary/route.ts index 8430194..3404bff 100644 --- a/app/api/events/summary/route.ts +++ b/app/api/events/summary/route.ts @@ -14,6 +14,8 @@ export async function GET(request: NextRequest) { // Add debug log to check if linkId is being received const linkId = searchParams.get('linkId'); console.log('Summary API received linkId:', linkId); + console.log('Summary API full parameters:', Object.fromEntries(searchParams.entries())); + console.log('Summary API URL:', request.url); const summary = await getEventsSummary({ startTime: searchParams.get('startTime') || undefined, diff --git a/app/api/shortlinks/[id]/route.ts b/app/api/shortlinks/[id]/route.ts new file mode 100644 index 0000000..72ea1c8 --- /dev/null +++ b/app/api/shortlinks/[id]/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { executeQuery } from '@/lib/clickhouse'; +import type { ApiResponse } from '@/lib/types'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + // Get the id from the URL parameters + const { id } = params; + + if (!id) { + return NextResponse.json({ + success: false, + error: 'ID parameter is required' + }, { status: 400 }); + } + + console.log('Fetching shortlink by ID:', id); + + // Query to fetch a single shortlink by id + const query = ` + SELECT + id, + external_id, + type, + slug, + original_url, + title, + description, + attributes, + schema_version, + creator_id, + creator_email, + creator_name, + created_at, + updated_at, + deleted_at, + projects, + teams, + tags, + qr_codes AS qr_codes, + channels, + favorites, + expires_at, + click_count, + unique_visitors + FROM shorturl_analytics.shorturl + WHERE id = '${id}' AND deleted_at IS NULL + LIMIT 1 + `; + + console.log('Executing query:', query); + + // Execute the query + const result = await executeQuery(query); + + // If no shortlink found with the specified ID + if (!Array.isArray(result) || result.length === 0) { + return NextResponse.json({ + success: false, + error: 'Shortlink not found' + }, { status: 404 }); + } + + // Process the shortlink data + const shortlink = result[0] as any; + + // Extract shortUrl from attributes + let shortUrl = ''; + try { + if (shortlink.attributes && typeof shortlink.attributes === 'string') { + const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string }; + shortUrl = attributes.shortUrl || ''; + } + } catch (e) { + console.error('Error parsing shortlink attributes:', e); + } + + // Process teams + let teams: any[] = []; + try { + if (shortlink.teams && typeof shortlink.teams === 'string') { + teams = JSON.parse(shortlink.teams); + } + } catch (e) { + console.error('Error parsing teams:', e); + } + + // Process tags + let tags: any[] = []; + try { + if (shortlink.tags && typeof shortlink.tags === 'string') { + tags = JSON.parse(shortlink.tags); + } + } catch (e) { + console.error('Error parsing tags:', e); + } + + // Process projects + let projects: any[] = []; + try { + if (shortlink.projects && typeof shortlink.projects === 'string') { + projects = JSON.parse(shortlink.projects); + } + } catch (e) { + console.error('Error parsing projects:', e); + } + + // Format the data to match what our store expects + const formattedShortlink = { + id: shortlink.id || '', + externalId: shortlink.external_id || '', + slug: shortlink.slug || '', + originalUrl: shortlink.original_url || '', + title: shortlink.title || '', + shortUrl: shortUrl, + teams: teams, + projects: projects, + tags: tags.map((tag: any) => tag.tag_name || ''), + createdAt: shortlink.created_at, + domain: new URL(shortUrl || 'https://example.com').hostname + }; + + const response: ApiResponse = { + success: true, + data: formattedShortlink + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching shortlink by ID:', error); + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/shortlinks/byUrl/route.ts b/app/api/shortlinks/byUrl/route.ts index f656ff9..57e1650 100644 --- a/app/api/shortlinks/byUrl/route.ts +++ b/app/api/shortlinks/byUrl/route.ts @@ -111,6 +111,7 @@ export async function GET(request: NextRequest) { // Format the data to match what our store expects const formattedShortlink = { id: shortlink.id || '', + externalId: shortlink.external_id || '', slug: shortlink.slug || '', originalUrl: shortlink.original_url || '', title: shortlink.title || '', @@ -122,6 +123,8 @@ export async function GET(request: NextRequest) { domain: new URL(shortUrl || 'https://example.com').hostname }; + console.log('Shortlink data formatted with externalId:', shortlink.external_id, 'Final object:', formattedShortlink); + const response: ApiResponse = { success: true, data: formattedShortlink diff --git a/app/api/shortlinks/exact/route.ts b/app/api/shortlinks/exact/route.ts new file mode 100644 index 0000000..48515f7 --- /dev/null +++ b/app/api/shortlinks/exact/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { executeQuery } from '@/lib/clickhouse'; +import type { ApiResponse } from '@/lib/types'; + +export async function GET(request: NextRequest) { + try { + // Get the url from query parameters + const searchParams = request.nextUrl.searchParams; + const shortUrl = searchParams.get('shortUrl'); + + if (!shortUrl) { + return NextResponse.json({ + success: false, + error: 'shortUrl parameter is required' + }, { status: 400 }); + } + + console.log('Fetching shortlink by exact shortUrl:', shortUrl); + + // Query to fetch a single shortlink by shortUrl in attributes + const query = ` + SELECT + id, + external_id, + type, + slug, + original_url, + title, + description, + attributes, + schema_version, + creator_id, + creator_email, + creator_name, + created_at, + updated_at, + deleted_at, + projects, + teams, + tags, + qr_codes AS qr_codes, + channels, + favorites, + expires_at, + click_count, + unique_visitors + FROM shorturl_analytics.shorturl + WHERE JSONHas(attributes, 'shortUrl') + AND JSONExtractString(attributes, 'shortUrl') = '${shortUrl}' + AND deleted_at IS NULL + LIMIT 1 + `; + + console.log('Executing query:', query); + + // Execute the query + const result = await executeQuery(query); + + // If no shortlink found with the specified URL + if (!Array.isArray(result) || result.length === 0) { + return NextResponse.json({ + success: false, + error: 'Shortlink not found' + }, { status: 404 }); + } + + // Process the shortlink data + const shortlink = result[0] as Record; + + // Extract shortUrl from attributes + let shortUrlValue = ''; + try { + if (shortlink.attributes && typeof shortlink.attributes === 'string') { + const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string }; + shortUrlValue = attributes.shortUrl || ''; + } + } catch (e) { + console.error('Error parsing shortlink attributes:', e); + } + + // Process teams + let teams: any[] = []; + try { + if (shortlink.teams && typeof shortlink.teams === 'string') { + teams = JSON.parse(shortlink.teams); + } + } catch (e) { + console.error('Error parsing teams:', e); + } + + // Process tags + let tags: any[] = []; + try { + if (shortlink.tags && typeof shortlink.tags === 'string') { + tags = JSON.parse(shortlink.tags); + } + } catch (e) { + console.error('Error parsing tags:', e); + } + + // Process projects + let projects: any[] = []; + try { + if (shortlink.projects && typeof shortlink.projects === 'string') { + projects = JSON.parse(shortlink.projects); + } + } catch (e) { + console.error('Error parsing projects:', e); + } + + // Format the data to match what our store expects + const formattedShortlink = { + id: shortlink.id || '', + externalId: shortlink.external_id || '', + slug: shortlink.slug || '', + originalUrl: shortlink.original_url || '', + title: shortlink.title || '', + shortUrl: shortUrlValue, + teams: teams, + projects: projects, + tags: tags.map((tag: any) => tag.tag_name || ''), + createdAt: shortlink.created_at, + domain: new URL(shortUrlValue || 'https://example.com').hostname + }; + + console.log('Formatted shortlink with externalId:', shortlink.external_id); + + const response: ApiResponse = { + success: true, + data: formattedShortlink + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching shortlink by exact URL:', error); + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/components/charts/TimeSeriesChart.tsx b/app/components/charts/TimeSeriesChart.tsx index 6215114..e918f7b 100644 --- a/app/components/charts/TimeSeriesChart.tsx +++ b/app/components/charts/TimeSeriesChart.tsx @@ -137,6 +137,11 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) { return date.toLocaleDateString(); } return ''; + }, + label: (context) => { + const label = context.dataset.label || ''; + const value = context.parsed.y; + return `${label}: ${Math.round(value)}`; } } } @@ -160,9 +165,9 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) { callback: (value: number) => { if (!value && value !== 0) return ''; if (value >= 1000) { - return `${(value / 1000).toFixed(1)}k`; + return `${Math.round(value / 1000)}k`; } - return value; + return Math.round(value); } } } diff --git a/app/links/page.tsx b/app/links/page.tsx index 9ff3456..9b00055 100644 --- a/app/links/page.tsx +++ b/app/links/page.tsx @@ -115,30 +115,77 @@ export default function LinksPage() { // 使用 Zustand store const { setSelectedShortUrl } = useShortUrlStore(); - // 处理链接记录点击 - const handleLinkClick = (shortUrl: string, link: ShortLink, metadata: any) => { - // 编码 shortUrl 以确保 URL 安全 - const encodedShortUrl = encodeURIComponent(shortUrl); + // 处理点击链接行 + const handleRowClick = (link: any) => { + // 解析 attributes 字符串为对象 + let attributes: Record = {}; + try { + if (link.attributes && typeof link.attributes === 'string') { + attributes = JSON.parse(link.attributes || '{}'); + } + } catch (e) { + console.error('Error parsing link attributes:', e); + } - // 创建完整的 ShortUrlData 对象 - const shortUrlData: ShortUrlData = { + // 解析 teams 字符串为数组 + let teams: any[] = []; + try { + if (link.teams && typeof link.teams === 'string') { + teams = JSON.parse(link.teams || '[]'); + } + } catch (e) { + console.error('Error parsing teams:', e); + } + + // 解析 projects 字符串为数组 + let projects: any[] = []; + try { + if (link.projects && typeof link.projects === 'string') { + projects = JSON.parse(link.projects || '[]'); + } + } catch (e) { + console.error('Error parsing projects:', e); + } + + // 解析 tags 字符串为数组 + let tags: string[] = []; + try { + if (link.tags && typeof link.tags === 'string') { + const parsedTags = JSON.parse(link.tags); + if (Array.isArray(parsedTags)) { + tags = parsedTags.map((tag: { tag_name?: string }) => tag.tag_name || ''); + } + } + } catch (e) { + console.error('Error parsing tags:', e); + } + + // 确保 shortUrl 存在 + const shortUrlValue = attributes.shortUrl || ''; + + // 提取用于显示的字段 + const shortUrlData = { id: link.id, - slug: metadata.slug, - originalUrl: metadata.originalUrl, - title: metadata.title, - shortUrl: shortUrl, - teams: metadata.teamNames, - tags: metadata.tagNames, - projects: metadata.projectNames, - createdAt: metadata.createdAt, - domain: metadata.domain + externalId: link.external_id, // 明确添加 externalId 字段 + slug: link.slug, + originalUrl: link.original_url, + title: link.title, + shortUrl: shortUrlValue, + teams: teams, + projects: projects, + tags: tags, + createdAt: link.created_at, + domain: shortUrlValue ? new URL(shortUrlValue).hostname : 'shorturl.example.com' }; - // 使用 Zustand store 保存数据 + // 打印完整数据,确保 externalId 被包含 + console.log('Setting shortURL data in store with externalId:', link.external_id); + + // 将数据保存到 Zustand store setSelectedShortUrl(shortUrlData); - // 导航到 analytics 页面并带上参数 - router.push(`/analytics?shorturl=${encodedShortUrl}`); + // 导航到分析页面,并在 URL 中包含 shortUrl 参数 + router.push(`/analytics?shorturl=${encodeURIComponent(shortUrlValue)}`); }; // Extract link metadata from attributes @@ -423,7 +470,7 @@ export default function LinksPage() { const shortUrl = `https://${metadata.domain}/${metadata.slug}`; return ( - handleLinkClick(shortUrl, link, metadata)}> + handleRowClick(link)}>
{metadata.title} diff --git a/app/utils/store.ts b/app/utils/store.ts index 9feedea..25acf6e 100644 --- a/app/utils/store.ts +++ b/app/utils/store.ts @@ -17,6 +17,7 @@ interface ProjectData { // 定义 ShortUrl 数据类型 export interface ShortUrlData { id: string; + externalId: string; slug: string; originalUrl: string; title?: string;