time chart int

This commit is contained in:
2025-04-08 07:46:20 +08:00
parent 4e7266240d
commit 6940d60510
8 changed files with 541 additions and 39 deletions

View File

@@ -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);
}
// Trigger data fetching after shortURL data is processed
setShouldFetchData(true);
// 强制保证 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);
}
} 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() {
</div>
)}
{/* Debug info - remove in production */}
{process.env.NODE_ENV !== 'production' && (
<div className="mb-4 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-80">
<h3 className="font-bold mb-1">Debug Info:</h3>
<div>
<strong>Hydrated:</strong> {isHydrated ? 'Yes' : 'No'} |
<strong> Should Fetch:</strong> {shouldFetchData ? 'Yes' : 'No'} |
<strong> Has ShortUrl:</strong> {selectedShortUrl ? 'Yes' : 'No'}
</div>
{selectedShortUrl && (
<div className="mt-1">
<strong>ShortUrl ID:</strong> {selectedShortUrl.id} |
<strong> ExternalId:</strong> {selectedShortUrl.externalId || 'MISSING'} |
<strong> URL:</strong> {selectedShortUrl.shortUrl}
</div>
)}
<div className="mt-1 text-xs text-red-500">
<strong>IMPORTANT: </strong>
The events table uses <code>external_id</code> as <code>link_id</code>, not the UUID format.
External ID format sample: <code>cm8x34sdr0007m11yh1xe6qc2</code>
</div>
{/* Full link data for debugging */}
{selectedShortUrl && (
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">Show Full Link Data</summary>
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
{JSON.stringify(selectedShortUrl, null, 2)}
</div>
</details>
</div>
)}
{/* URL Parameters */}
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">API Request URLs</summary>
<div className="mt-2">
<div><strong>Summary API URL:</strong> {`/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()}`}</div>
</div>
</details>
</div>
{/* Local Storage Data */}
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">LocalStorage Data</summary>
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
{typeof window !== 'undefined' && localStorage.getItem('shorturl-storage') ?
JSON.stringify(JSON.parse(localStorage.getItem('shorturl-storage') || '{}'), null, 2) :
'No localStorage data'}
</div>
</details>
</div>
</div>
)}
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
@@ -424,7 +585,8 @@ export default function AnalyticsPage() {
</div>
<div className="text-xs mt-1 text-blue-700">
<span>Analytics filtered for this short URL only</span>
{selectedShortUrl.id && <span className="ml-2 text-blue-500">(ID: {selectedShortUrl.id})</span>}
{selectedShortUrl.id && <span className="ml-2 text-blue-500">(ID: {selectedShortUrl.id.substring(0, 8)}...)</span>}
{selectedShortUrl.externalId && <span className="ml-2 text-blue-500">(External ID: {selectedShortUrl.externalId})</span>}
</div>
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -727,7 +889,7 @@ export default function AnalyticsPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="font-medium">{info.linkName}</span>
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
ID: {event.link_id?.substring(0, 8) || '-'}
ID: {event.link_id || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">

View File

@@ -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,

View File

@@ -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<typeof formattedShortlink> = {
success: true,
data: formattedShortlink
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching shortlink by ID:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -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<typeof formattedShortlink> = {
success: true,
data: formattedShortlink

View File

@@ -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<string, any>;
// 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<typeof formattedShortlink> = {
success: true,
data: formattedShortlink
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching shortlink by exact URL:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<string, any> = {};
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 (
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleLinkClick(shortUrl, link, metadata)}>
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleRowClick(link)}>
<td className="px-6 py-4">
<div className="flex flex-col space-y-1">
<span className="font-medium text-gray-900">{metadata.title}</span>

View File

@@ -17,6 +17,7 @@ interface ProjectData {
// 定义 ShortUrl 数据类型
export interface ShortUrlData {
id: string;
externalId: string;
slug: string;
originalUrl: string;
title?: string;