76 Commits

Author SHA1 Message Date
53e1611670 auto refresh 2025-04-17 18:28:08 +08:00
6025641ab1 sync events intime 2025-04-17 14:22:50 +08:00
b9c2828e54 Add domain field to shortlink API responses and sync script 2025-04-16 21:32:49 +08:00
b1753449f5 sync url 2025-04-16 20:55:48 +08:00
85f29d8b49 click subpath match 2025-04-10 18:31:24 +08:00
b8cd3716c4 click subpath 2025-04-10 18:07:10 +08:00
48d5bdafa4 click subpath 2025-04-10 17:19:40 +08:00
ace231b93f rm parameters 2025-04-10 15:10:26 +08:00
e101d19e00 add path cont 2025-04-10 12:14:54 +08:00
a8576121e9 add path cont 2025-04-10 11:44:03 +08:00
8b407975e5 sync & read me 2025-04-09 19:20:40 +08:00
ede83068af fix modal 2025-04-08 12:41:41 +08:00
d21026eafd fix build 2025-04-08 12:00:58 +08:00
6940d60510 time chart int 2025-04-08 07:46:20 +08:00
4e7266240d persisten storge 2025-04-08 07:04:02 +08:00
db70602e9f hide filter 2025-04-08 00:03:13 +08:00
d0e83f697b take shorturl data 2025-04-07 23:20:48 +08:00
ed327ad3f0 change route 2025-04-07 22:27:02 +08:00
f782dba0c9 links info 2025-04-07 22:17:53 +08:00
0c4a67e769 links search 2025-04-07 22:08:12 +08:00
694e005101 links 2025-04-07 21:58:28 +08:00
523e99a001 links 2025-04-07 21:54:05 +08:00
33dbf62665 links 2025-04-07 21:48:24 +08:00
1a9e28bd7e show label 2025-04-03 21:57:55 +08:00
d1d21948b6 tag fix 2025-04-03 17:56:16 +08:00
f32a45d24a utm 2025-04-03 17:50:45 +08:00
d61b8a62ff utm 2025-04-03 16:27:04 +08:00
0b41f3ea42 geo main 2025-04-02 22:23:49 +08:00
63f434fd93 geo tab 2025-04-02 21:36:13 +08:00
95f230b996 geo 2025-04-02 20:50:10 +08:00
0f8419778c device ana 2025-04-02 20:18:08 +08:00
a6f7172ec4 fix filter 2025-04-02 20:05:33 +08:00
8054b0235d events trend filters 2025-04-02 18:05:45 +08:00
b0dbd088e7 rm no used page 2025-04-02 17:56:15 +08:00
bf7c62fdc9 events pages 2025-04-02 10:04:36 +08:00
9cb9f62686 events filter 2025-04-02 08:55:46 +08:00
4b7fb7a887 rearrange pages 2025-04-01 23:44:01 +08:00
bdae5c164c move events pos 2025-04-01 23:04:15 +08:00
9fa61ccf8d summary filter 2025-04-01 23:00:35 +08:00
b187bdefdf sidebar collapse 2025-04-01 22:51:11 +08:00
87c3803236 add filter 2025-04-01 22:40:33 +08:00
75adb36111 rm evets id filter 2025-04-01 22:35:10 +08:00
a4ef2c3147 events team 2025-04-01 22:34:07 +08:00
57e16144a9 team filter 2025-04-01 22:26:46 +08:00
1be6a6dbf0 move page 2025-04-01 22:18:25 +08:00
36f22059e9 move device 2025-04-01 21:22:23 +08:00
a8d364be1f tags selector 2025-04-01 20:09:49 +08:00
326a6c6d63 project selector 2025-04-01 20:03:15 +08:00
0a881fd180 team selector 2025-04-01 19:51:30 +08:00
1b901bda90 rm dack 2025-04-01 19:43:30 +08:00
53822f1087 team selector 2025-04-01 19:04:13 +08:00
1978e0224e supabase client tool 2025-04-01 17:45:12 +08:00
c0649ce10f component supabase 2025-04-01 17:36:28 +08:00
696a434b95 login page 2025-04-01 14:57:22 +08:00
b8e6180212 dashboard data 2025-04-01 12:40:57 +08:00
6beb6c3666 rm swagger 2025-04-01 12:00:13 +08:00
17b588e249 use next dev 2025-04-01 11:51:27 +08:00
26db8fe76d percent 2025-03-28 15:15:24 +08:00
4ad505cda1 fix build 2025-03-26 21:50:04 +08:00
7a03396cdd add event 2025-03-26 20:19:37 +08:00
e9b9950ed3 add event example desc 2025-03-26 19:21:57 +08:00
f5b14bf936 event track api 2025-03-26 18:19:37 +08:00
ca8a7d56f1 swagerr doc 2025-03-26 17:39:58 +08:00
913c9cd289 swagger configure 2025-03-26 17:17:47 +08:00
e916eab92c mv folder 2025-03-26 16:39:04 +08:00
63a578ef38 event api 2025-03-26 12:04:53 +08:00
b4aa765c17 event api 2025-03-26 11:26:53 +08:00
c0e5a9ccb2 add pie chart 2025-03-26 11:18:36 +08:00
1755b44a39 style 2025-03-25 22:24:52 +08:00
e0ac87fb25 events 2025-03-25 21:12:03 +08:00
ecf21a812f dashboard page good 2025-03-25 21:02:17 +08:00
efdfe8bf8e front 2025-03-25 20:54:02 +08:00
92d82b18a0 events apis 2025-03-25 17:26:04 +08:00
1e9e5928d7 sync trace & short to clickhouse events 2025-03-25 14:35:01 +08:00
231cf556b0 table sql 2025-03-25 13:03:01 +08:00
3413d3e182 table sql 2025-03-25 12:56:20 +08:00
105 changed files with 14551 additions and 7896 deletions

View File

@@ -0,0 +1,47 @@
# Date Format Handling for ClickHouse Events API
## Problem Description
The event tracking API was experiencing issues with date format compatibility when inserting records into the ClickHouse database. ClickHouse has specific requirements for datetime formats, particularly for its `DateTime64` type fields, which weren't being properly addressed in the original implementation.
## Root Cause
- JavaScript's default date serialization (`toISOString()`) produces formats like `2023-08-24T12:34:56.789Z`, which include `T` as a separator and `Z` as the UTC timezone indicator
- ClickHouse prefers datetime values in the format `YYYY-MM-DD HH:MM:SS.SSS` for seamless parsing
- The mismatch between these formats was causing insertion errors in the database
## Solution Implemented
We created a `formatDateTime` utility function that properly formats JavaScript Date objects for ClickHouse compatibility:
```typescript
const formatDateTime = (date: Date) => {
return date.toISOString().replace('T', ' ').replace('Z', '');
};
```
This function:
1. Takes a JavaScript Date object as input
2. Converts it to ISO format string
3. Replaces the 'T' separator with a space
4. Removes the trailing 'Z' UTC indicator
The solution was applied to all date fields in the event payload:
- `event_time`
- `link_created_at`
- `link_expires_at`
## Additional Improvements
- We standardized date handling by using a consistent `currentTime` variable
- Added type checking for JSON fields to ensure proper serialization
- Improved error handling for date parsing failures
## Best Practices for ClickHouse Date Handling
1. Always format dates as `YYYY-MM-DD HH:MM:SS.SSS` when inserting into ClickHouse
2. Use consistent date handling utilities across your application
3. Consider timezone handling explicitly when needed
4. For query parameters, use ClickHouse's `parseDateTimeBestEffort` function when possible
5. Test with various date formats and edge cases to ensure robustness

28
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
import '../globals.css';
import type { Metadata } from 'next';
import { Sidebar } from '@/app/components/Sidebar';
export const metadata: Metadata = {
title: 'ShortURL Analytics',
description: 'Analytics for your shortened URLs',
};
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen bg-gray-50">
{/* 侧边栏 */}
<Sidebar />
{/* 主内容区域 */}
<div className="flex-1 flex flex-col overflow-auto">
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
);
}

65
app/(app)/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
export default function HomePage() {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
Welcome to ShortURL Analytics
</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<a
href="/dashboard"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Dashboard
</h2>
<p className="text-gray-600">
Get an overview of all your short URL analytics data.
</p>
</a>
<a
href="/events"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Event Tracking
</h2>
<p className="text-gray-600">
View detailed events for all your short URLs.
</p>
</a>
<a
href="/analytics"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
URL Analysis
</h2>
<p className="text-gray-600">
Analyze performance of specific short URLs.
</p>
</a>
<a
href="/account"
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Account Settings
</h2>
<p className="text-gray-600">
Manage your account and team settings.
</p>
</a>
</div>
</div>
);
}

1119
app/analytics/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDeviceAnalysis } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 获取设备分析详情
const analysisData = await getDeviceAnalysis(
startDate,
endDate,
linkId
);
// 返回数据
return NextResponse.json(analysisData);
} catch (error) {
console.error('Error in device-analysis API:', error);
return NextResponse.json(
{ error: 'Failed to fetch device analysis data' },
{ status: 500 }
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConversionFunnel } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId');
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 验证必要参数
if (!linkId) {
return NextResponse.json(
{ error: 'Missing required parameter: linkId' },
{ status: 400 }
);
}
// 获取转化漏斗数据
const funnelData = await getConversionFunnel(
linkId,
startDate || undefined,
endDate || undefined
);
// 返回数据
return NextResponse.json(funnelData);
} catch (error) {
console.error('Error in funnel API:', error);
return NextResponse.json(
{ error: 'Failed to fetch funnel data' },
{ status: 500 }
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLinkPerformance } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId');
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 验证必要参数
if (!linkId) {
return NextResponse.json(
{ error: 'Missing required parameter: linkId' },
{ status: 400 }
);
}
// 获取链接表现数据
const performanceData = await getLinkPerformance(
linkId,
startDate || undefined,
endDate || undefined
);
// 返回数据
return NextResponse.json(performanceData);
} catch (error) {
console.error('Error in link-performance API:', error);
return NextResponse.json(
{ error: 'Failed to fetch link performance data' },
{ status: 500 }
);
}
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLinkStatusDistribution } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 获取链接状态分布数据
const distributionData = await getLinkStatusDistribution(
startDate,
endDate,
projectId
);
// 返回数据
return NextResponse.json(distributionData);
} catch (error) {
console.error('Error in link-status-distribution API:', error);
return NextResponse.json(
{ error: 'Failed to fetch link status distribution data' },
{ status: 500 }
);
}
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getOverviewCards } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 获取概览卡片数据
const cardsData = await getOverviewCards(
startDate,
endDate,
projectId
);
// 返回数据
return NextResponse.json(cardsData);
} catch (error) {
console.error('Error in overview-cards API:', error);
return NextResponse.json(
{ error: 'Failed to fetch overview cards data' },
{ status: 500 }
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLinkOverview } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId');
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 验证必要参数
if (!linkId) {
return NextResponse.json(
{ error: 'Missing required parameter: linkId' },
{ status: 400 }
);
}
// 获取链接概览数据
const overviewData = await getLinkOverview(
linkId,
startDate || undefined,
endDate || undefined
);
// 返回数据
return NextResponse.json(overviewData);
} catch (error) {
console.error('Error in overview API:', error);
return NextResponse.json(
{ error: 'Failed to fetch overview data' },
{ status: 500 }
);
}
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlatformDistribution } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 获取平台分布数据
const distributionData = await getPlatformDistribution(
startDate,
endDate,
linkId
);
// 返回数据
return NextResponse.json(distributionData);
} catch (error) {
console.error('Error in platform-distribution API:', error);
return NextResponse.json(
{ error: 'Failed to fetch platform distribution data' },
{ status: 500 }
);
}
}

View File

@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPopularLinks } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
const sortBy = searchParams.get('sortBy') as 'visits' | 'uniqueVisitors' | 'conversionRate' || 'visits';
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10;
// 获取热门链接数据
const linksData = await getPopularLinks(
startDate,
endDate,
projectId,
sortBy,
limit
);
// 返回数据
return NextResponse.json(linksData);
} catch (error) {
console.error('Error in popular-links API:', error);
return NextResponse.json(
{ error: 'Failed to fetch popular links data' },
{ status: 500 }
);
}
}

View File

@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPopularReferrers } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
const type = searchParams.get('type') as 'domain' | 'full' || 'domain';
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10;
// 获取热门引荐来源数据
const referrersData = await getPopularReferrers(
startDate,
endDate,
linkId,
type,
limit
);
// 返回数据
return NextResponse.json(referrersData);
} catch (error) {
console.error('Error in popular-referrers API:', error);
return NextResponse.json(
{ error: 'Failed to fetch popular referrers data' },
{ status: 500 }
);
}
}

View File

@@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQrCodeAnalysis } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId') || undefined;
const qrCodeId = searchParams.get('qrCodeId') || undefined;
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
// 获取QR码分析数据
const analysisData = await getQrCodeAnalysis(
startDate,
endDate,
linkId,
qrCodeId
);
// 返回数据
return NextResponse.json(analysisData);
} catch (error) {
console.error('Error in qr-code-analysis API:', error);
return NextResponse.json(
{ error: 'Failed to fetch QR code analysis data' },
{ status: 500 }
);
}
}

View File

@@ -1,68 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { trackEvent, EventType, ConversionType } from '@/lib/analytics';
export async function POST(request: NextRequest) {
try {
// 解析请求体
const body = await request.json();
// 验证必要字段
if (!body.linkId) {
return NextResponse.json(
{ error: 'Missing required field: linkId' },
{ status: 400 }
);
}
if (!body.eventType || !Object.values(EventType).includes(body.eventType)) {
return NextResponse.json(
{
error: 'Invalid or missing eventType',
validValues: Object.values(EventType)
},
{ status: 400 }
);
}
// 验证转化类型(如果提供)
if (
body.conversionType &&
!Object.values(ConversionType).includes(body.conversionType)
) {
return NextResponse.json(
{
error: 'Invalid conversionType',
validValues: Object.values(ConversionType)
},
{ status: 400 }
);
}
// 添加客户端IP
const clientIp = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'0.0.0.0';
// 添加用户代理
const userAgent = request.headers.get('user-agent') || '';
// 合并数据
const eventData = {
...body,
ipAddress: body.ipAddress || clientIp,
userAgent: body.userAgent || userAgent,
};
// 追踪事件
const result = await trackEvent(eventData);
// 返回结果
return NextResponse.json(result);
} catch (error) {
console.error('Error in track API:', error);
return NextResponse.json(
{ error: 'Failed to track event' },
{ status: 500 }
);
}
}

View File

@@ -1,50 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getVisitTrends, TimeGranularity } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
// 获取请求参数
const { searchParams } = new URL(request.url);
const linkId = searchParams.get('linkId');
const startDate = searchParams.get('startDate') || undefined;
const endDate = searchParams.get('endDate') || undefined;
const granularity = searchParams.get('granularity') as TimeGranularity || TimeGranularity.DAY;
// 验证必要参数
if (!linkId) {
return NextResponse.json(
{ error: 'Missing required parameter: linkId' },
{ status: 400 }
);
}
// 验证粒度参数
const validGranularities = Object.values(TimeGranularity);
if (granularity && !validGranularities.includes(granularity)) {
return NextResponse.json(
{
error: 'Invalid granularity value',
validValues: validGranularities
},
{ status: 400 }
);
}
// 获取访问趋势数据
const trendsData = await getVisitTrends(
linkId,
startDate || undefined,
endDate || undefined,
granularity
);
// 返回数据
return NextResponse.json(trendsData);
} catch (error) {
console.error('Error in trends API:', error);
return NextResponse.json(
{ error: 'Failed to fetch trends data' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
import { getDeviceAnalytics } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const data = await getDeviceAnalytics({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined,
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
// 添加子路径筛选
subpath: searchParams.get('subpath') || undefined
});
const response: ApiResponse<typeof data> = {
success: true,
data
};
return NextResponse.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
import { getGeoAnalytics } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
// Get the groupBy parameter
const groupBy = searchParams.get('groupBy') as 'country' | 'city' | 'region' | 'continent' | null;
const data = await getGeoAnalytics({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined,
groupBy: groupBy || undefined,
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
// 添加子路径筛选
subpath: searchParams.get('subpath') || undefined
});
const response: ApiResponse<typeof data> = {
success: true,
data
};
return NextResponse.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
import { executeQuery } from '@/lib/clickhouse';
export async function GET(request: NextRequest) {
try {
// 获取查询参数
const searchParams = request.nextUrl.searchParams;
const startTime = searchParams.get('startTime');
const endTime = searchParams.get('endTime');
const linkId = searchParams.get('linkId');
if (!startTime || !endTime || !linkId) {
return NextResponse.json({
success: false,
error: 'Missing required parameters'
}, { status: 400 });
}
// 查询链接的点击事件
const query = `
SELECT event_attributes
FROM events
WHERE link_id = '${linkId}'
AND event_time >= parseDateTimeBestEffort('${startTime}')
AND event_time <= parseDateTimeBestEffort('${endTime}')
AND event_type = 'click'
`;
const events = await executeQuery(query);
// 处理事件数据,按路径分组
const pathMap = new Map<string, number>();
let totalClicks = 0;
events.forEach((event: any) => {
try {
if (event.event_attributes) {
const attrs = JSON.parse(event.event_attributes);
if (attrs.full_url) {
// 提取URL的路径和参数部分
const url = new URL(attrs.full_url);
const pathWithParams = url.pathname + (url.search || '');
// 更新路径计数
const currentCount = pathMap.get(pathWithParams) || 0;
pathMap.set(pathWithParams, currentCount + 1);
totalClicks++;
}
}
} catch (error) {
// 忽略解析错误
}
});
// 转换为数组并按点击数排序
const pathData = Array.from(pathMap.entries())
.map(([path, count]) => ({
path,
count,
percentage: totalClicks > 0 ? count / totalClicks : 0,
}))
.sort((a, b) => b.count - a.count);
const response: ApiResponse<typeof pathData> = {
success: true,
data: pathData,
meta: { total: totalClicks }
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching path analytics data:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Internal server error'
};
return NextResponse.json(response, { status: 500 });
}
}

75
app/api/events/route.ts Normal file
View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEvents, EventsQueryParams } from '@/lib/analytics';
import { ApiResponse } from '@/lib/types';
// 获取事件列表
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// 获取查询参数
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const eventType = searchParams.get('eventType') || undefined;
const linkId = searchParams.get('linkId') || undefined;
const linkSlug = searchParams.get('linkSlug') || undefined;
const userId = searchParams.get('userId') || undefined;
const subpath = searchParams.get('subpath') || undefined;
// 获取可能存在的多个团队、项目和标签ID
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
const sortBy = searchParams.get('sortBy') || undefined;
const sortOrder = (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined;
console.log("API接收到的tagIds:", tagIds); // 添加日志便于调试
console.log("API接收到的subpath:", subpath); // 添加日志便于调试
// 获取事件列表
const params: EventsQueryParams = {
page,
pageSize,
eventType,
linkId,
linkSlug,
userId,
subpath,
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
startTime,
endTime,
sortBy,
sortOrder
};
// 记录完整的参数用于调试
console.log("完整请求参数:", JSON.stringify(params));
const result = await getEvents(params);
const response: ApiResponse<typeof result.events> = {
success: true,
data: result.events,
meta: {
total: result.total,
page,
pageSize
}
};
return NextResponse.json(response);
} catch (error) {
console.error('获取事件列表失败:', error);
const response: ApiResponse<null> = {
success: false,
data: null,
error: error instanceof Error ? error.message : '获取事件列表失败'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
import { getEventsSummary } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取可能存在的多个团队、项目和标签ID
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
// Add debug log to check if linkId is being received
const linkId = searchParams.get('linkId');
const subpath = searchParams.get('subpath');
console.log('Summary API received linkId:', linkId);
console.log('Summary API received subpath:', subpath);
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,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined,
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
subpath: searchParams.get('subpath') || undefined
});
const response: ApiResponse<typeof summary> = {
success: true,
data: summary
};
return NextResponse.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
import { getTimeSeriesData } from '@/lib/analytics';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const startTime = searchParams.get('startTime');
const endTime = searchParams.get('endTime');
if (!startTime || !endTime) {
return NextResponse.json({
success: false,
error: 'startTime and endTime are required'
}, { status: 400 });
}
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const data = await getTimeSeriesData({
startTime,
endTime,
linkId: searchParams.get('linkId') || undefined,
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month',
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
// 添加子路径筛选
subpath: searchParams.get('subpath') || undefined
});
const response: ApiResponse<typeof data> = {
success: true,
data
};
return NextResponse.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -0,0 +1,208 @@
# 事件跟踪接口说明
## 概述
该接口用于跟踪用户交互事件并将数据存储到 ClickHouse 数据库中。支持记录各种类型的事件,并可包含与链接、用户、团队、项目等相关的详细信息。
## 接口信息
- **URL**: `/api/events/track`
- **方法**: `POST`
- **Content-Type**: `application/json`
## 请求参数
### 必填字段
| 参数 | 类型 | 描述 |
|------|------|------|
| `event_type` | string | 事件类型,如 'click', 'view', 'conversion' |
### 核心事件字段
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `event_id` | string | 否 | 事件唯一标识符不提供时自动生成UUID |
| `event_time` | string/Date | 否 | 事件发生时间格式为ISO日期字符串默认为当前时间 |
| `event_attributes` | object/string | 否 | 事件相关的其他属性可以是JSON对象或JSON字符串 |
### 链接信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `link_id` | string | 否 | 短链接的唯一ID |
| `link_slug` | string | 否 | 短链接的slug部分 |
| `link_label` | string | 否 | 短链接的显示名称 |
| `link_title` | string | 否 | 短链接的标题 |
| `link_original_url` | string | 否 | 原始目标URL |
| `link_attributes` | object/string | 否 | 链接相关的额外属性 |
| `link_created_at` | string/Date | 否 | 链接创建时间 |
| `link_expires_at` | string/Date | 否 | 链接过期时间 |
| `link_tags` | array/string | 否 | 链接标签可以是数组或JSON字符串 |
### 用户信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `user_id` | string | 否 | 用户ID |
| `user_name` | string | 否 | 用户名称 |
| `user_email` | string | 否 | 用户邮箱 |
| `user_attributes` | object/string | 否 | 用户相关的其他属性 |
### 团队和项目信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `team_id` | string | 否 | 团队ID |
| `team_name` | string | 否 | 团队名称 |
| `team_attributes` | object/string | 否 | 团队相关的其他属性 |
| `project_id` | string | 否 | 项目ID |
| `project_name` | string | 否 | 项目名称 |
| `project_attributes` | object/string | 否 | 项目相关的其他属性 |
### 二维码信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `qr_code_id` | string | 否 | 二维码ID |
| `qr_code_name` | string | 否 | 二维码名称 |
| `qr_code_attributes` | object/string | 否 | 二维码相关的其他属性 |
### 访问者信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `visitor_id` | string | 否 | 访问者唯一标识符,不提供时自动生成 |
| `session_id` | string | 否 | 会话ID不提供时自动生成 |
| `ip_address` | string | 否 | 访问者IP地址默认从请求头获取 |
| `country` | string | 否 | 访问者所在国家 |
| `city` | string | 否 | 访问者所在城市 |
| `device_type` | string | 否 | 设备类型 (如 desktop, mobile, tablet) |
| `browser` | string | 否 | 浏览器名称 |
| `os` | string | 否 | 操作系统 |
| `user_agent` | string | 否 | 用户代理字符串,默认从请求头获取 |
### 引荐来源信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `referrer` | string | 否 | 引荐URL默认从请求头获取 |
| `utm_source` | string | 否 | UTM来源参数 |
| `utm_medium` | string | 否 | UTM媒介参数 |
| `utm_campaign` | string | 否 | UTM活动参数 |
| `utm_term` | string | 否 | UTM术语参数 |
| `utm_content` | string | 否 | UTM内容参数 |
### 交互信息
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| `time_spent_sec` | number | 否 | 用户在页面上停留的时间默认0 |
| `is_bounce` | boolean | 否 | 是否是跳出只访问一个页面默认true |
| `is_qr_scan` | boolean | 否 | 是否来自二维码扫描默认false |
| `conversion_type` | string | 否 | 转化类型 |
| `conversion_value` | number | 否 | 转化价值默认0 |
## 响应格式
### 成功响应 (201 Created)
```json
{
"success": true,
"message": "Event tracked successfully",
"event_id": "uuid-of-tracked-event"
}
```
### 错误响应
#### 缺少必填字段 (400 Bad Request)
```json
{
"error": "Missing required field: event_type"
}
```
#### 服务器错误 (500 Internal Server Error)
```json
{
"error": "Failed to track event",
"details": "具体错误信息"
}
```
## 使用示例
### 基本事件跟踪请求
```javascript
fetch('/api/events/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_type: 'click',
link_id: 'abc123',
link_slug: 'promo-summer',
link_original_url: 'https://example.com/summer-promotion'
})
})
```
### 详细事件跟踪请求
```javascript
fetch('/api/events/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_type: 'conversion',
link_id: 'abc123',
link_slug: 'promo-summer',
link_original_url: 'https://example.com/summer-promotion',
event_attributes: {
page: '/checkout',
product_id: 'xyz789'
},
user_id: 'user123',
team_id: 'team456',
project_id: 'proj789',
visitor_id: 'vis987',
is_bounce: false,
time_spent_sec: 120,
conversion_type: 'purchase',
conversion_value: 99.99,
utm_source: 'email',
utm_campaign: 'summer_sale'
})
})
```
## 注意事项
- 所有对象类型的字段(如 `event_attributes`可以作为对象或预先格式化的JSON字符串传递
- 如果不提供 `event_id``visitor_id``session_id`,系统将自动生成
- 时间戳字段接受ISO格式的日期字符串并会被转换为ClickHouse兼容的格式
UTM 测试示例。1. 电子邮件营销链接
https://short.domain.com/summer?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header
说明: 用于电子邮件营销活动,跟踪用户从邮件头部横幅点击的流量。
2. 社交媒体广告链接
https://short.domain.com/product?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story
说明: 用于 Instagram Story 广告,跟踪用户从社交媒体故事广告点击的情况。
3. 搜索引擎广告链接
https://short.domain.com/service?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name
说明: 用于 Google Ads 广告,跟踪用户从搜索引擎付费广告点击的流量,特别是针对特定搜索词。
4. QR 码链接
https://short.domain.com/event?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr
说明: 用于打印材料上的 QR 码,跟踪用户扫描实体宣传资料的情况。
5. 合作伙伴引荐链接
https://short.domain.com/partner?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner
说明: 用于合作伙伴网站上的推广横幅,跟踪来自联盟营销的转化率。
https://upj.to/5seaii?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header
https://upj.to/5seaii?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story
https://upj.to/5seaii?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name
https://upj.to/5seaii?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr
https://upj.to/5seaii?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { Event } from '../../types';
import { v4 as uuid } from 'uuid';
import clickhouse from '@/lib/clickhouse';
// 将时间格式化为ClickHouse兼容的格式YYYY-MM-DD HH:MM:SS.SSS
const formatDateTime = (date: Date) => {
return date.toISOString().replace('T', ' ').replace('Z', '');
};
// Handler for POST request to track events
export async function POST(req: NextRequest) {
try {
// Parse request body
const eventData = await req.json();
// Validate required fields
if (!eventData.event_type) {
return NextResponse.json(
{ error: 'Missing required field: event_type' },
{ status: 400 }
);
}
// 获取当前时间并格式化
const currentTime = formatDateTime(new Date());
// Set default values for required fields if missing
const event: Event = {
// Core event fields
event_id: eventData.event_id || uuid(),
event_time: eventData.event_time ? formatDateTime(new Date(eventData.event_time)) : currentTime,
event_type: eventData.event_type,
event_attributes: eventData.event_attributes || '{}',
// Link information
link_id: eventData.link_id || '',
link_slug: eventData.link_slug || '',
link_label: eventData.link_label || '',
link_title: eventData.link_title || '',
link_original_url: eventData.link_original_url || '',
link_attributes: eventData.link_attributes || '{}',
link_created_at: eventData.link_created_at ? formatDateTime(new Date(eventData.link_created_at)) : currentTime,
link_expires_at: eventData.link_expires_at ? formatDateTime(new Date(eventData.link_expires_at)) : null,
link_tags: eventData.link_tags || '[]',
// User information
user_id: eventData.user_id || '',
user_name: eventData.user_name || '',
user_email: eventData.user_email || '',
user_attributes: eventData.user_attributes || '{}',
// Team information
team_id: eventData.team_id || '',
team_name: eventData.team_name || '',
team_attributes: eventData.team_attributes || '{}',
// Project information
project_id: eventData.project_id || '',
project_name: eventData.project_name || '',
project_attributes: eventData.project_attributes || '{}',
// QR code information
qr_code_id: eventData.qr_code_id || '',
qr_code_name: eventData.qr_code_name || '',
qr_code_attributes: eventData.qr_code_attributes || '{}',
// Visitor information
visitor_id: eventData.visitor_id || uuid(),
session_id: eventData.session_id || uuid(),
ip_address: eventData.ip_address || req.headers.get('x-forwarded-for')?.toString() || '',
country: eventData.country || '',
city: eventData.city || '',
device_type: eventData.device_type || '',
browser: eventData.browser || '',
os: eventData.os || '',
user_agent: eventData.user_agent || req.headers.get('user-agent')?.toString() || '',
// Referrer information
referrer: eventData.referrer || req.headers.get('referer')?.toString() || '',
utm_source: eventData.utm_source || '',
utm_medium: eventData.utm_medium || '',
utm_campaign: eventData.utm_campaign || '',
utm_term: eventData.utm_term || '',
utm_content: eventData.utm_content || '',
// Interaction information
time_spent_sec: eventData.time_spent_sec || 0,
is_bounce: eventData.is_bounce !== undefined ? eventData.is_bounce : true,
is_qr_scan: eventData.is_qr_scan !== undefined ? eventData.is_qr_scan : false,
conversion_type: eventData.conversion_type || '',
conversion_value: eventData.conversion_value || 0,
};
// 确保JSON字符串字段的正确处理
if (typeof event.event_attributes === 'object') {
event.event_attributes = JSON.stringify(event.event_attributes);
}
if (typeof event.link_attributes === 'object') {
event.link_attributes = JSON.stringify(event.link_attributes);
}
if (typeof event.user_attributes === 'object') {
event.user_attributes = JSON.stringify(event.user_attributes);
}
if (typeof event.team_attributes === 'object') {
event.team_attributes = JSON.stringify(event.team_attributes);
}
if (typeof event.project_attributes === 'object') {
event.project_attributes = JSON.stringify(event.project_attributes);
}
if (typeof event.qr_code_attributes === 'object') {
event.qr_code_attributes = JSON.stringify(event.qr_code_attributes);
}
if (typeof event.link_tags === 'object') {
event.link_tags = JSON.stringify(event.link_tags);
}
// Insert event into ClickHouse
await clickhouse.insert({
table: 'events',
values: [event],
format: 'JSONEachRow',
});
// Return success response
return NextResponse.json({
success: true,
message: 'Event tracked successfully',
event_id: event.event_id
}, { status: 201 });
} catch (error) {
console.error('Error tracking event:', error);
return NextResponse.json(
{ error: 'Failed to track event', details: (error as Error).message },
{ status: 500 }
);
}
}

201
app/api/events/utm/route.ts Normal file
View File

@@ -0,0 +1,201 @@
import { NextRequest, NextResponse } from 'next/server';
import clickhouse from '@/lib/clickhouse';
import type { ApiResponse } from '@/lib/types';
interface UtmData {
utm_value: string;
clicks: number;
visitors: number;
avg_time_spent: number;
bounces: number;
conversions: number;
}
// 辅助函数,将日期格式化为标准格式
function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toISOString().split('.')[0];
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取过滤参数
const startTime = searchParams.get('startTime');
const endTime = searchParams.get('endTime');
const linkId = searchParams.get('linkId');
const subpath = searchParams.get('subpath');
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const tagNames = searchParams.getAll('tagName');
// 获取UTM类型参数
const utmType = searchParams.get('utmType') || 'source';
// 添加调试日志
console.log('UTM API received parameters:', {
startTime,
endTime,
linkId,
subpath,
teamIds,
projectIds,
tagIds,
tagNames,
utmType,
url: request.url
});
// 构建WHERE子句
let whereClause = '';
const conditions = [];
if (startTime) {
conditions.push(`event_time >= toDateTime('${formatDateTime(startTime)}')`);
}
if (endTime) {
conditions.push(`event_time <= toDateTime('${formatDateTime(endTime)}')`);
}
if (linkId) {
conditions.push(`link_id = '${linkId}'`);
}
// 添加子路径筛选 - 使用更精确的匹配方式
if (subpath && subpath.trim() !== '') {
console.log('====== UTM API SUBPATH DEBUG ======');
console.log('Raw subpath param:', subpath);
// 清理并准备subpath值
let cleanSubpath = subpath.trim();
// 移除开头的斜杠以便匹配
if (cleanSubpath.startsWith('/')) {
cleanSubpath = cleanSubpath.substring(1);
}
// 移除结尾的斜杠以便匹配
if (cleanSubpath.endsWith('/')) {
cleanSubpath = cleanSubpath.substring(0, cleanSubpath.length - 1);
}
console.log('Cleaned subpath:', cleanSubpath);
// 使用正则表达式匹配URL中的第二个路径部分
// 示例: 在 "https://abc.com/slug/subpath/" 中匹配 "subpath"
const condition = `match(JSONExtractString(event_attributes, 'full_url'), '/[^/]+/${cleanSubpath}(/|\\\\?|$)')`;
console.log('Final SQL condition:', condition);
console.log('==================================');
conditions.push(condition);
}
// 添加团队筛选
if (teamIds && teamIds.length > 0) {
// 如果只有一个团队ID
if (teamIds.length === 1) {
conditions.push(`team_id = '${teamIds[0]}'`);
} else {
// 多个团队ID
conditions.push(`team_id IN ('${teamIds.join("','")}')`);
}
}
// 添加项目筛选
if (projectIds && projectIds.length > 0) {
// 如果只有一个项目ID
if (projectIds.length === 1) {
conditions.push(`project_id = '${projectIds[0]}'`);
} else {
// 多个项目ID
conditions.push(`project_id IN ('${projectIds.join("','")}')`);
}
}
// 添加标签筛选
if ((tagIds && tagIds.length > 0) || (tagNames && tagNames.length > 0)) {
// 优先使用tagNames如果有的话
const tagsToUse = tagNames.length > 0 ? tagNames : tagIds;
// 使用与buildFilter函数相同的处理方式
const tagConditions = tagsToUse.map(tag =>
`link_tags LIKE '%${tag}%'`
);
conditions.push(`(${tagConditions.join(' OR ')})`);
}
if (conditions.length > 0) {
whereClause = `WHERE ${conditions.join(' AND ')}`;
}
// 确定要分组的UTM字段
let utmField;
switch (utmType) {
case 'source':
utmField = 'utm_source';
break;
case 'medium':
utmField = 'utm_medium';
break;
case 'campaign':
utmField = 'utm_campaign';
break;
case 'term':
utmField = 'utm_term';
break;
case 'content':
utmField = 'utm_content';
break;
default:
utmField = 'utm_source';
}
// 构建SQL查询
const query = `
SELECT
${utmField} AS utm_value,
COUNT(*) AS clicks,
uniqExact(visitor_id) AS visitors,
round(AVG(time_spent_sec), 2) AS avg_time_spent,
countIf(is_bounce = 1) AS bounces,
countIf(conversion_type IN ('visit', 'stay', 'interact', 'signup', 'subscription', 'purchase')) AS conversions
FROM shorturl_analytics.events
${whereClause}
${whereClause ? 'AND' : 'WHERE'} ${utmField} != ''
GROUP BY utm_value
ORDER BY clicks DESC
LIMIT 100
`;
// 执行查询
const result = await clickhouse.query({
query,
format: 'JSONEachRow',
});
// 获取查询结果
const rows = await result.json();
const data = rows as UtmData[];
// 返回数据
const response: ApiResponse<UtmData[]> = {
success: true,
data
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching UTM data:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

248
app/api/geo/batch/route.ts Normal file
View File

@@ -0,0 +1,248 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
interface IpLocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
timestamp: number;
}
// Simple in-memory cache on the server side to reduce API calls
const serverCache: Record<string, IpLocationData> = {};
// Cache for IPs that have repeatedly failed to resolve
const failedIPsCache: Record<string, { attempts: number, lastAttempt: number }> = {};
// Cache expiration time (30 days in milliseconds)
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // 30 days
// Max attempts to fetch an IP before considering it permanently failed
const MAX_RETRY_ATTEMPTS = 3;
// Retry timeout - how long to wait before trying a failed IP again (24 hours)
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
/**
* Check if an IP has failed too many times and should be skipped
*/
function shouldSkipIP(ip: string): boolean {
if (!failedIPsCache[ip]) return false;
const now = Date.now();
// Skip if max attempts reached
if (failedIPsCache[ip].attempts >= MAX_RETRY_ATTEMPTS) {
return true;
}
// Skip if last attempt was recent
if (now - failedIPsCache[ip].lastAttempt < RETRY_TIMEOUT) {
return true;
}
return false;
}
/**
* Mark an IP as failed
*/
function markIPAsFailed(ip: string): void {
const now = Date.now();
if (failedIPsCache[ip]) {
failedIPsCache[ip] = {
attempts: failedIPsCache[ip].attempts + 1,
lastAttempt: now
};
} else {
failedIPsCache[ip] = {
attempts: 1,
lastAttempt: now
};
}
}
/**
* Get location data for a single IP using ipapi.co
*/
async function fetchIpLocation(ip: string): Promise<IpLocationData | null> {
try {
// Skip this IP if it has failed too many times
if (shouldSkipIP(ip)) {
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
return null;
}
// Check server cache first
const now = Date.now();
if (serverCache[ip] && (now - serverCache[ip].timestamp) < CACHE_EXPIRATION) {
return serverCache[ip];
}
// Add delay to avoid rate limiting (100 requests per minute max)
await new Promise(resolve => setTimeout(resolve, 600)); // ~100 req/min = 1 req per 600ms
const response = await fetch(`https://ipapi.co/${ip}/json/`);
if (!response.ok) {
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
markIPAsFailed(ip);
return null;
}
const data = await response.json();
if (data.error) {
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
markIPAsFailed(ip);
return null;
}
// Reset failed status if successful
if (failedIPsCache[ip]) {
delete failedIPsCache[ip];
}
const locationData: IpLocationData = {
ip: data.ip,
country_name: data.country_name || 'Unknown',
country_code: data.country_code || 'UN',
city: data.city || 'Unknown',
region: data.region || 'Unknown',
continent_code: data.continent_code || 'UN',
continent_name: getContinentName(data.continent_code) || 'Unknown',
latitude: data.latitude || 0,
longitude: data.longitude || 0,
timestamp: Date.now()
};
// Cache the result
serverCache[ip] = locationData;
return locationData;
} catch (error) {
console.error(`Error fetching location for IP ${ip}:`, error);
markIPAsFailed(ip);
return null;
}
}
/**
* Get continent name from continent code
*/
function getContinentName(code?: string): string {
if (!code) return 'Unknown';
const continents: Record<string, string> = {
'AF': 'Africa',
'AN': 'Antarctica',
'AS': 'Asia',
'EU': 'Europe',
'NA': 'North America',
'OC': 'Oceania',
'SA': 'South America'
};
return continents[code] || 'Unknown';
}
/**
* API route handler for batch IP location lookups
*/
export async function POST(request: NextRequest) {
try {
const { ips } = await request.json();
if (!ips || !Array.isArray(ips) || ips.length === 0) {
return NextResponse.json({
success: false,
error: 'Invalid or empty IP list'
}, { status: 400 });
}
// Limit batch size to 50 IPs to prevent abuse
const ipList = ips.slice(0, 50);
const results: Record<string, IpLocationData | null> = {};
// Filter out IPs that should be skipped
const validIPs = ipList.filter(ip => {
if (typeof ip !== 'string' || !ip.trim()) return false;
if (isPrivateIP(ip)) {
results[ip] = getPrivateIPData(ip);
return false;
}
if (shouldSkipIP(ip)) {
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
results[ip] = null;
return false;
}
return true;
});
// Process remaining IPs sequentially to respect rate limits
for (const ip of validIPs) {
results[ip] = await fetchIpLocation(ip);
}
const response: ApiResponse<Record<string, IpLocationData | null>> = {
success: true,
data: results
};
return NextResponse.json(response);
} catch (error) {
console.error('Batch IP lookup error:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}
/**
* Check if IP is a private/local address
*/
function isPrivateIP(ip: string): boolean {
return (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('127.') ||
ip === 'localhost' ||
ip === '::1'
);
}
/**
* Generate location data for private IP addresses
*/
function getPrivateIPData(ip: string): IpLocationData {
return {
ip,
country_name: 'Local Network',
country_code: 'LO',
city: 'Local',
region: 'Local',
continent_code: 'LO',
continent_name: 'Local',
latitude: 0,
longitude: 0,
timestamp: Date.now()
};
}

View File

@@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLinkDetailsById } from '@/app/api/links/service';
// 正确的Next.js 15 API路由处理函数参数类型定义
export async function GET(
request: NextRequest,
context: { params: Promise<any> }
) {
try {
// 获取参数,支持异步格式
const params = await context.params;
const linkId = params.linkId;
const link = await getLinkDetailsById(linkId);
if (!link) {
return NextResponse.json(
{ error: 'Link not found' },
{ status: 404 }
);
}
return NextResponse.json(link);
} catch (error) {
console.error('Failed to fetch link details:', error);
return NextResponse.json(
{ error: 'Failed to fetch link details', message: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,29 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLinkById } from '../service';
export async function GET(
request: NextRequest,
context: { params: Promise<any> }
) {
try {
// 获取参数,支持异步格式
const params = await context.params;
const linkId = params.linkId;
const link = await getLinkById(linkId);
if (!link) {
return NextResponse.json(
{ error: 'Link not found' },
{ status: 404 }
);
}
return NextResponse.json(link);
} catch (error) {
console.error('Failed to fetch link:', error);
return NextResponse.json(
{ error: 'Failed to fetch link', message: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,157 +0,0 @@
import { executeQuery, executeQuerySingle } from '@/lib/clickhouse';
import { Link, LinkQueryParams } from '../types';
/**
* Find links with filtering options
*/
export async function findLinks({
limit = 10,
offset = 0,
searchTerm = '',
tagFilter = '',
isActive = null,
}: LinkQueryParams) {
// Build WHERE conditions
const conditions = [];
if (searchTerm) {
conditions.push(`
(lower(title) LIKE lower('%${searchTerm}%') OR
lower(original_url) LIKE lower('%${searchTerm}%'))
`);
}
if (tagFilter) {
conditions.push(`hasAny(tags, ['${tagFilter}'])`);
}
if (isActive !== null) {
conditions.push(`is_active = ${isActive ? 'true' : 'false'}`);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
// Get total count
const countQuery = `
SELECT count() as total
FROM links
${whereClause}
`;
const countData = await executeQuery<{ total: number }>(countQuery);
const total = countData.length > 0 ? countData[0].total : 0;
// 使用左连接获取链接数据和统计信息
const linksQuery = `
SELECT
l.link_id,
l.original_url,
l.created_at,
l.created_by,
l.title,
l.description,
l.tags,
l.is_active,
l.expires_at,
l.team_id,
l.project_id,
count(le.event_id) as visits,
count(DISTINCT le.visitor_id) as unique_visits
FROM links l
LEFT JOIN link_events le ON l.link_id = le.link_id
${whereClause}
GROUP BY
l.link_id,
l.original_url,
l.created_at,
l.created_by,
l.title,
l.description,
l.tags,
l.is_active,
l.expires_at,
l.team_id,
l.project_id
ORDER BY l.created_at DESC
LIMIT ${limit}
OFFSET ${offset}
`;
const links = await executeQuery<Link>(linksQuery);
return {
links,
total,
limit,
offset,
page: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(total / limit)
};
}
/**
* Find a single link by ID
*/
export async function findLinkById(linkId: string): Promise<Link | null> {
const query = `
SELECT
l.link_id,
l.original_url,
l.created_at,
l.created_by,
l.title,
l.description,
l.tags,
l.is_active,
l.expires_at,
l.team_id,
l.project_id,
count(le.event_id) as visits,
count(DISTINCT le.visitor_id) as unique_visits
FROM links l
LEFT JOIN link_events le ON l.link_id = le.link_id
WHERE l.link_id = '${linkId}'
GROUP BY
l.link_id,
l.original_url,
l.created_at,
l.created_by,
l.title,
l.description,
l.tags,
l.is_active,
l.expires_at,
l.team_id,
l.project_id
LIMIT 1
`;
return await executeQuerySingle<Link>(query);
}
/**
* Find a single link by ID - only basic info without statistics
*/
export async function findLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | null> {
const query = `
SELECT
link_id,
original_url,
created_at,
created_by,
title,
description,
tags,
is_active,
expires_at,
team_id,
project_id
FROM links
WHERE link_id = '${linkId}'
LIMIT 1
`;
return await executeQuerySingle<Omit<Link, 'visits' | 'unique_visits'>>(query);
}

View File

@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { LinkQueryParams } from '../types';
import { getLinks } from './service';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// Parse request parameters
const params: LinkQueryParams = {
limit: searchParams.has('limit') ? Number(searchParams.get('limit')) : 10,
page: searchParams.has('page') ? Number(searchParams.get('page')) : 1,
searchTerm: searchParams.get('search') || '',
tagFilter: searchParams.get('tag') || '',
};
// Handle active status filter
const activeFilter = searchParams.get('active');
if (activeFilter === 'true') params.isActive = true;
if (activeFilter === 'false') params.isActive = false;
// Get link data
const result = await getLinks(params);
return NextResponse.json(result);
} catch (error) {
console.error('Failed to fetch links:', error);
return NextResponse.json(
{ error: 'Failed to fetch links', message: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,42 +0,0 @@
import { Link, LinkQueryParams, PaginatedResponse } from '../types';
import { findLinkById, findLinkDetailsById, findLinks } from './repository';
/**
* Get links with pagination information
*/
export async function getLinks(params: LinkQueryParams): Promise<PaginatedResponse<Link>> {
// Convert page number to offset
const { page, limit = 10, ...otherParams } = params;
const offset = page ? (page - 1) * limit : params.offset || 0;
const result = await findLinks({
...otherParams,
limit,
offset
});
return {
data: result.links,
pagination: {
total: result.total,
limit: result.limit,
offset: result.offset,
page: result.page,
totalPages: result.totalPages
}
};
}
/**
* Get a single link by ID with full details (including statistics)
*/
export async function getLinkById(linkId: string): Promise<Link | null> {
return await findLinkById(linkId);
}
/**
* Get a single link by ID - only basic info without statistics
*/
export async function getLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | null> {
return await findLinkDetailsById(linkId);
}

View File

@@ -0,0 +1,141 @@
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,
domain
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: shortlink.domain || (shortUrl ? new URL(shortUrl).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

@@ -0,0 +1,143 @@
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 url = searchParams.get('url');
if (!url) {
return NextResponse.json({
success: false,
error: 'URL parameter is required'
}, { status: 400 });
}
console.log('Fetching shortlink by URL:', url);
// 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,
domain
FROM shorturl_analytics.shorturl
WHERE JSONHas(attributes, 'shortUrl')
AND JSONExtractString(attributes, 'shortUrl') = '${url}'
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];
// Extract shortUrl from attributes
let shortUrl = '';
try {
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
const attributes = JSON.parse(shortlink.attributes);
shortUrl = attributes.shortUrl || '';
}
} catch (e) {
console.error('Error parsing shortlink attributes:', e);
}
// Process teams
let teams = [];
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 = [];
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 = [];
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) => tag.tag_name || ''),
createdAt: shortlink.created_at,
domain: shortlink.domain || (shortUrl ? new URL(shortUrl).hostname : '')
};
console.log('Shortlink data formatted with externalId:', shortlink.external_id, 'Final object:', formattedShortlink);
const response: ApiResponse<typeof formattedShortlink> = {
success: true,
data: formattedShortlink
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching shortlink by 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

@@ -0,0 +1,143 @@
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,
domain
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: shortlink.domain || (shortUrlValue ? new URL(shortUrlValue).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 });
}
}

104
app/api/shortlinks/route.ts Normal file
View File

@@ -0,0 +1,104 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/clickhouse';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
try {
// Get pagination and filter parameters from the URL
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('page_size') || '10', 10);
const search = searchParams.get('search');
const team = searchParams.get('team');
// Calculate OFFSET
const offset = (page - 1) * pageSize;
// Build WHERE conditions
const whereConditions = ['deleted_at IS NULL'];
if (search) {
// Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url
whereConditions.push(`(
slug ILIKE '%${search}%' OR
original_url ILIKE '%${search}%' OR
title ILIKE '%${search}%' OR
JSONHas(attributes, 'shortUrl') AND JSONExtractString(attributes, 'shortUrl') ILIKE '%${search}%' OR
arrayExists(x -> JSONExtractString(x, 'team_name') ILIKE '%${search}%', JSONExtractArrayRaw(teams)) OR
arrayExists(x -> JSONExtractString(x, 'tag_name') ILIKE '%${search}%', JSONExtractArrayRaw(tags))
)`);
}
if (team) {
whereConditions.push(`arrayExists(x -> JSONExtractString(x, 'team_id') = '${team}', JSONExtractArrayRaw(teams))`);
}
const whereClause = whereConditions.join(' AND ');
// First query to get total count
const countQuery = `
SELECT count(*) as total
FROM shorturl_analytics.shorturl
WHERE ${whereClause}
`;
const countResult = await executeQuery(countQuery);
// Handle the result safely by using an explicit type check
const total = Array.isArray(countResult) && countResult.length > 0 && typeof countResult[0] === 'object' && countResult[0] !== null && 'total' in countResult[0]
? Number(countResult[0].total)
: 0;
const totalPages = Math.ceil(total / pageSize);
// Main query with pagination
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,
domain
FROM shorturl_analytics.shorturl
WHERE ${whereClause}
ORDER BY created_at DESC
LIMIT ${pageSize} OFFSET ${offset}
`;
// Execute the query using the shared client
const rows = await executeQuery(query);
// Return the data with pagination metadata
return NextResponse.json({
links: rows,
total: total,
total_pages: totalPages,
page: page,
page_size: pageSize
});
} catch (error) {
console.error('Error fetching shortlinks from ClickHouse:', error);
return NextResponse.json(
{ error: 'Failed to fetch shortlinks' },
{ status: 500 }
);
}
}

View File

@@ -1,21 +0,0 @@
import { executeQuerySingle } from '@/lib/clickhouse';
import { StatsOverview } from '../types';
/**
* Get overview statistics for links
*/
export async function findStatsOverview(): Promise<StatsOverview | null> {
const query = `
WITH
toUInt64(count()) as total_links,
toUInt64(countIf(is_active = true)) as active_links
FROM links
SELECT
total_links as totalLinks,
active_links as activeLinks,
(SELECT count() FROM link_events) as totalVisits,
(SELECT count() FROM link_events) / NULLIF(total_links, 0) as conversionRate
`;
return await executeQuerySingle<StatsOverview>(query);
}

View File

@@ -1,15 +0,0 @@
import { NextResponse } from 'next/server';
import { getStatsOverview } from './service';
export async function GET() {
try {
const stats = await getStatsOverview();
return NextResponse.json(stats);
} catch (error) {
console.error('获取统计概览失败:', error);
return NextResponse.json(
{ error: '获取统计概览失败', message: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,21 +0,0 @@
import { StatsOverview } from '../types';
import { findStatsOverview } from './repository';
/**
* Get link statistics overview
*/
export async function getStatsOverview(): Promise<StatsOverview> {
const stats = await findStatsOverview();
// Return default values if no data
if (!stats) {
return {
totalLinks: 0,
activeLinks: 0,
totalVisits: 0,
conversionRate: 0
};
}
return stats;
}

View File

@@ -1,19 +0,0 @@
import { executeQuery } from '@/lib/clickhouse';
import { Tag } from '../types';
/**
* Get all tags with usage counts
*/
export async function findAllTags(): Promise<Tag[]> {
const query = `
SELECT
tag,
count() as count
FROM links
ARRAY JOIN tags as tag
GROUP BY tag
ORDER BY count DESC
`;
return await executeQuery<Tag>(query);
}

View File

@@ -1,15 +0,0 @@
import { NextResponse } from 'next/server';
import { getAllTags } from './service';
export async function GET() {
try {
const tags = await getAllTags();
return NextResponse.json(tags);
} catch (error) {
console.error('Failed to fetch tags:', error);
return NextResponse.json(
{ error: 'Failed to fetch tags', message: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,9 +0,0 @@
import { Tag } from '../types';
import { findAllTags } from './repository';
/**
* Get all available tags
*/
export async function getAllTags(): Promise<Tag[]> {
return await findAllTags();
}

View File

@@ -0,0 +1,41 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const supabase = createRouteHandlerClient({ cookies });
// 获取当前用户
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 获取用户所属的所有团队
const { data: teams, error: teamsError } = await supabase
.from('teams')
.select(`
id,
name,
description,
avatar_url
`)
.innerJoin('team_membership', 'teams.id = team_membership.team_id')
.eq('team_membership.user_id', user.id)
.is('teams.deleted_at', null);
if (teamsError) {
console.error('Error fetching teams:', teamsError);
return NextResponse.json({ error: 'Failed to fetch teams' }, { status: 500 });
}
return NextResponse.json(teams);
} catch (error) {
console.error('Error in /api/teams/list:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,221 +1,168 @@
// 链接数据类型
export interface Link {
// Event Types
export interface Event {
// 核心事件信息
event_id: string;
event_time: string;
event_type: string;
event_attributes: string;
// 链接信息
link_id: string;
original_url: string;
created_at: string;
created_by: string;
title: string;
description: string;
tags: string[];
is_active: boolean;
expires_at: string | null;
link_slug: string;
link_label: string;
link_title: string;
link_original_url: string;
link_attributes: string;
link_created_at: string;
link_expires_at: string | null;
link_tags: string;
// 用户信息
user_id: string;
user_name: string;
user_email: string;
user_attributes: string;
// 团队信息
team_id: string;
team_name: string;
team_attributes: string;
// 项目信息
project_id: string;
project_name: string;
project_attributes: string;
// 二维码信息
qr_code_id: string;
qr_code_name: string;
qr_code_attributes: string;
// 访问者信息
visitor_id: string;
session_id: string;
ip_address: string;
country: string;
city: string;
device_type: string;
browser: string;
os: string;
user_agent: string;
// 来源信息
referrer: string;
utm_source: string;
utm_medium: string;
utm_campaign: string;
utm_term: string;
utm_content: string;
// 交互信息
time_spent_sec: number;
is_bounce: boolean;
is_qr_scan: boolean;
conversion_type: string;
conversion_value: number;
// 旧接口兼容字段
id?: string;
time?: string;
type?: string;
linkInfo?: {
id: string;
shortUrl: string;
originalUrl: string;
};
visitor?: {
id: string;
browser: string;
os: string;
device: string;
};
location?: {
country: string;
region: string;
city: string;
};
}
// Analytics Types
export interface TimeSeriesData {
timestamp: string;
events: number;
visitors: number;
conversions: number;
}
export interface GeoData {
location: string;
area: string;
visits: number;
unique_visits: number;
visitors: number;
percentage: number;
}
// 分页响应类型
export interface PaginatedResponse<T> {
data: T[];
pagination: {
total: number;
limit: number;
offset: number;
page: number;
totalPages: number;
}
export type DeviceType = 'mobile' | 'desktop' | 'tablet' | 'other';
export interface DeviceAnalytics {
deviceTypes: {
type: string;
count: number;
percentage: number;
}[];
browsers: {
name: string;
count: number;
percentage: number;
}[];
operatingSystems: {
name: string;
count: number;
percentage: number;
}[];
}
// 链接查询参数
export interface LinkQueryParams {
limit?: number;
offset?: number;
page?: number;
searchTerm?: string;
tagFilter?: string;
isActive?: boolean | null;
}
// 标签类型
export interface Tag {
tag: string;
count: number;
}
// 统计概览类型
export interface StatsOverview {
totalLinks: number;
activeLinks: number;
totalVisits: number;
conversionRate: number;
}
// Analytics数据类型
export interface LinkOverviewData {
totalVisits: number;
export interface EventsSummary {
totalEvents: number;
uniqueVisitors: number;
totalConversions: number;
averageTimeSpent: number;
bounceCount: number;
conversionCount: number;
uniqueReferrers: number;
deviceTypes: {
mobile: number;
tablet: number;
desktop: number;
tablet: number;
other: number;
};
qrScanCount: number;
totalConversionValue: number;
browsers: {
name: string;
count: number;
percentage: number;
}[];
operatingSystems: {
name: string;
count: number;
percentage: number;
}[];
}
export interface FunnelStep {
name: string;
value: number;
percent: number;
}
export interface ConversionFunnelData {
steps: FunnelStep[];
export interface ConversionStats {
totalConversions: number;
conversionRate: number;
averageValue: number;
byType: {
type: string;
count: number;
percentage: number;
value: number;
}[];
}
export interface TrendPoint {
timestamp: string;
visits: number;
uniqueVisitors: number;
}
export interface VisitTrendsData {
trends: TrendPoint[];
totals: {
visits: number;
uniqueVisitors: number;
};
}
export interface TrackEventRequest {
linkId: string;
eventType: string;
visitorId?: string;
sessionId?: string;
referrer?: string;
userAgent?: string;
ipAddress?: string;
timeSpent?: number;
conversionType?: string;
conversionValue?: number;
customData?: Record<string, unknown>;
isQrScan?: boolean;
qrCodeId?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
}
export interface TrackEventResponse {
success: boolean;
eventId: string;
timestamp: string;
}
// 链接表现数据
export interface LinkPerformanceData {
totalClicks: number;
uniqueVisitors: number;
averageTimeSpent: number;
bounceRate: number;
uniqueReferrers: number;
conversionRate: number;
activeDays: number;
lastClickTime: string | null;
deviceDistribution: {
mobile: number;
desktop: number;
};
}
// 平台分布数据
export interface PlatformItem {
name: string;
count: number;
percent: number;
}
export interface PlatformDistributionData {
totalVisits: number;
platforms: PlatformItem[];
browsers: PlatformItem[];
}
// 设备分析数据
export interface DeviceItem {
name: string;
count: number;
percent: number;
}
export interface DeviceModelItem {
type: string;
brand: string;
model: string;
count: number;
percent: number;
}
export interface DeviceAnalysisData {
totalVisits: number;
deviceTypes: DeviceItem[];
deviceBrands: DeviceItem[];
deviceModels: DeviceModelItem[];
}
// 热门引荐来源数据
export interface ReferrerItem {
source: string;
visitCount: number;
uniqueVisitors: number;
conversionCount: number;
conversionRate: number;
averageTimeSpent: number;
percent: number;
}
export interface PopularReferrersData {
referrers: ReferrerItem[];
totalVisits: number;
}
// QR码分析数据
export interface LocationItem {
city: string;
country: string;
scanCount: number;
percent: number;
}
export interface DeviceDistributionItem {
type: string;
count: number;
percent: number;
}
export interface HourlyDistributionItem {
hour: number;
scanCount: number;
percent: number;
}
export interface QrCodeAnalysisData {
overview: {
totalScans: number;
uniqueScanners: number;
conversionCount: number;
conversionRate: number;
averageTimeSpent: number;
};
locations: LocationItem[];
deviceDistribution: DeviceDistributionItem[];
hourlyDistribution: HourlyDistributionItem[];
export interface EventFilters {
startTime?: string;
endTime?: string;
eventType?: string;
linkId?: string;
linkSlug?: string;
page?: number;
pageSize?: number;
}

111
app/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
BarChartIcon,
HomeIcon,
PersonIcon,
ChevronLeftIcon,
ChevronRightIcon
} from '@radix-ui/react-icons';
interface NavItemProps {
href: string;
label: string;
icon: React.ReactNode;
isCollapsed: boolean;
isActive?: boolean;
}
const NavItem = ({ href, label, icon, isCollapsed, isActive }: NavItemProps) => {
return (
<Link
href={href}
className={`flex items-center p-2 rounded-lg ${
isActive
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
} transition-all duration-200 group`}
>
<div className="w-6 h-6 flex items-center justify-center">{icon}</div>
{!isCollapsed && (
<span className={`ml-3 whitespace-nowrap transition-opacity duration-200 ${
isCollapsed ? 'opacity-0 w-0' : 'opacity-100'
}`}>
{label}
</span>
)}
{isCollapsed && (
<span className="sr-only">{label}</span>
)}
</Link>
);
};
export function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const pathname = usePathname();
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
};
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: <HomeIcon className="w-5 h-5" /> },
{ name: 'Analytics', href: '/analytics', icon: <BarChartIcon className="w-5 h-5" /> },
{ name: 'Account', href: '/account', icon: <PersonIcon className="w-5 h-5" /> },
];
return (
<div className={`flex flex-col h-full transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-64'
} bg-white border-r border-gray-200 relative`}>
{/* 顶部Logo和标题 */}
<div className="flex items-center p-4 border-b border-gray-200">
<div className="flex-shrink-0 flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded">
<span className="font-bold">S</span>
</div>
{!isCollapsed && (
<span className="ml-3 font-medium text-gray-900 transition-opacity duration-200">
ShortURL Analytics
</span>
)}
</div>
{/* 导航菜单 */}
<div className="flex-grow p-4 overflow-y-auto">
<ul className="space-y-2">
{navigation.map((item) => (
<li key={item.name}>
<NavItem
href={item.href}
label={item.name}
icon={item.icon}
isCollapsed={isCollapsed}
isActive={pathname?.startsWith(item.href)}
/>
</li>
))}
</ul>
</div>
{/* 底部折叠按钮 */}
<div className="border-t border-gray-200 p-4">
<button
onClick={toggleSidebar}
className="w-full flex items-center justify-center p-2 rounded-lg text-gray-500 hover:bg-gray-100"
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRightIcon className="w-5 h-5" />
) : (
<ChevronLeftIcon className="w-5 h-5" />
)}
{!isCollapsed && <span className="ml-2">Collapse</span>}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
interface CategoryItem {
name: string;
count: number;
percentage: number;
}
interface DeviceAnalyticsProps {
data: DeviceAnalyticsType;
}
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
const renderCategory = (items: CategoryItem[], title: string) => (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="space-y-4">
{items.map((item, index) => (
<div key={index}>
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>{item.name}</span>
<span>{item.percentage.toFixed(1)}% ({item.count})</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))}
</div>
</div>
);
// Prepare device types data
const deviceItems = data.deviceTypes.map(item => ({
name: item.type || 'Unknown',
count: item.count,
percentage: item.percentage
}));
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{renderCategory(deviceItems, 'Device Types')}
{renderCategory(data.browsers, 'Browsers')}
{renderCategory(data.operatingSystems, 'Operating Systems')}
</div>
);
}

View File

@@ -0,0 +1,274 @@
"use client";
import { useState, useEffect } from 'react';
import { GeoData } from '@/app/api/types';
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
interface GeoAnalyticsProps {
data: GeoData[];
}
// Interface for IP location data in our cache
interface IpLocationDetail {
country: string;
city: string;
region: string;
continent: string;
}
// Cache for IP location data
interface LocationCache {
[key: string]: IpLocationDetail;
}
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
const [locationCache, setLocationCache] = useState<LocationCache>({});
const [isLoading, setIsLoading] = useState(false);
// Track IPs that failed to resolve
const [failedIPs, setFailedIPs] = useState<Set<string>>(new Set());
// 安全地格式化数字
const formatNumber = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
return value.toLocaleString();
};
// 安全地格式化百分比
const formatPercent = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
return value.toFixed(1);
};
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
// Handle tab selection - only change local view mode
const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => {
setViewMode(mode);
};
// Load location data for all IPs when the data changes
useEffect(() => {
const fetchLocations = async () => {
if (sortedData.length === 0) return;
setIsLoading(true);
const tempCache: LocationCache = {...locationCache};
const tempFailedIPs = new Set(failedIPs);
// Get all unique IPs that aren't already in the cache and haven't failed
const uniqueIPs = [...new Set(sortedData.map(item => item.location))].filter(ip =>
ip &&
ip !== 'Unknown' &&
!tempCache[ip] &&
!tempFailedIPs.has(ip)
);
if (uniqueIPs.length === 0) {
setIsLoading(false);
return;
}
try {
// Use batch lookup for better performance
const batchResults = await getLocationsFromIPs(uniqueIPs);
// Convert results to our cache format
for (const [ip, data] of Object.entries(batchResults)) {
if (data) {
tempCache[ip] = {
country: data.country_name,
city: data.city,
region: data.region,
continent: data.continent_name
};
} else {
// Mark as failed
tempFailedIPs.add(ip);
}
}
setLocationCache(tempCache);
setFailedIPs(tempFailedIPs);
} catch (error) {
console.error('Error fetching location data:', error);
} finally {
setIsLoading(false);
}
};
fetchLocations();
}, [data]);
// Get the appropriate location value based on the current view mode
const getLocationValue = (item: GeoData): string => {
const ip = item.location || '';
// If there's no IP or it's "Unknown", return that value
if (!ip || ip === 'Unknown') return 'Unknown';
// If IP failed to resolve, return Unknown
if (failedIPs.has(ip)) {
return 'Unknown';
}
// Return from cache if available
if (locationCache[ip]) {
switch (viewMode) {
case 'country':
return locationCache[ip].country || 'Unknown';
case 'city':
return locationCache[ip].city || 'Unknown';
case 'region':
return locationCache[ip].region || 'Unknown';
case 'continent':
return locationCache[ip].continent || 'Unknown';
default:
return ip;
}
}
// Return placeholder if not in cache yet
return `Loading...`;
};
// Get the appropriate area value based on the current view mode
const getAreaValue = (item: GeoData): string => {
const ip = item.location || '';
// If there's no IP or it's "Unknown", return empty string
if (!ip || ip === 'Unknown' || failedIPs.has(ip)) return '';
// Return from cache if available
if (locationCache[ip]) {
switch (viewMode) {
case 'country':
// For country view, show the continent as area
return locationCache[ip].continent || '';
case 'city':
// For city view, show the country and region
return `${locationCache[ip].country}, ${locationCache[ip].region}`;
case 'region':
// For region view, show the country
return locationCache[ip].country || '';
case 'continent':
// For continent view, no additional area needed
return '';
default:
return '';
}
}
// Return empty if not in cache yet
return '';
};
return (
<div>
{/* Tabs for geographic levels */}
<div className="flex border-b mb-6">
<button
onClick={() => handleViewModeChange('country')}
className={`px-4 py-2 ${viewMode === 'country' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Countries
</button>
<button
onClick={() => handleViewModeChange('city')}
className={`px-4 py-2 ${viewMode === 'city' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Cities
</button>
<button
onClick={() => handleViewModeChange('region')}
className={`px-4 py-2 ${viewMode === 'region' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Regions
</button>
<button
onClick={() => handleViewModeChange('continent')}
className={`px-4 py-2 ${viewMode === 'continent' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Continents
</button>
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center items-center py-2 mb-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
<span className="text-sm text-gray-500">Loading location data...</span>
</div>
)}
{/* Table with added area column */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{viewMode === 'country' ? 'Country' :
viewMode === 'city' ? 'City' :
viewMode === 'region' ? 'Region' : 'Continent'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{viewMode === 'country' ? 'Continent' :
viewMode === 'city' ? 'Location' :
viewMode === 'region' ? 'Country' : 'Area'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
% of Total
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedData.length > 0 ? (
sortedData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{getLocationValue(item)}
{item.location && (
<div className="text-xs text-gray-500 mt-1">{item.location}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{getAreaValue(item)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visits)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
<span className="ml-2">{formatPercent(item.percentage)}%</span>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
No location data available
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
interface PathAnalyticsProps {
startTime: string;
endTime: string;
linkId?: string;
onPathClick?: (path: string) => void;
}
interface PathData {
path: string;
count: number;
percentage: number;
}
const PathAnalytics: React.FC<PathAnalyticsProps> = ({ startTime, endTime, linkId, onPathClick }) => {
const [loading, setLoading] = useState(true);
const [pathData, setPathData] = useState<PathData[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!linkId) {
setLoading(false);
return;
}
const fetchPathData = async () => {
try {
const params = new URLSearchParams({
startTime,
endTime,
linkId
});
const response = await fetch(`/api/events/path-analytics?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch path analytics data');
}
const result = await response.json();
if (result.success && result.data) {
// 自定义处理路径数据,根据是否有子路径来分组
const rawData = result.data;
const pathMap = new Map<string, number>();
let totalClicks = 0;
rawData.forEach((item: PathData) => {
const urlPath = item.path.split('?')[0];
totalClicks += item.count;
// 解析路径,检查是否有子路径
const pathParts = urlPath.split('/').filter(Boolean);
// 基础路径(例如/5seaii或者带有查询参数但没有子路径的路径视为同一个路径
// 子路径(例如/5seaii/bbbbb单独统计
const groupKey = pathParts.length > 1 ? urlPath : `/${pathParts[0]}`;
const currentCount = pathMap.get(groupKey) || 0;
pathMap.set(groupKey, currentCount + item.count);
});
// 转换回数组并排序
const groupedPathData = Array.from(pathMap.entries())
.map(([path, count]) => ({
path,
count,
percentage: totalClicks > 0 ? count / totalClicks : 0,
}))
.sort((a, b) => b.count - a.count);
setPathData(groupedPathData);
} else {
setError(result.error || 'Failed to load path analytics');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchPathData();
}, [startTime, endTime, linkId]);
const handlePathClick = (path: string, e: React.MouseEvent) => {
e.preventDefault();
console.log('====== PATH CLICK DEBUG ======');
console.log('Path value:', path);
console.log('Path type:', typeof path);
console.log('Path length:', path.length);
console.log('Path chars:', Array.from(path).map(c => c.charCodeAt(0)));
console.log('==============================');
if (onPathClick) {
onPathClick(path);
}
};
if (loading) {
return <div className="py-8 flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
</div>;
}
if (error) {
return <div className="py-4 text-red-500">{error}</div>;
}
if (!linkId) {
return <div className="py-4 text-gray-500">Select a specific link to view path analytics.</div>;
}
if (pathData.length === 0) {
return <div className="py-4 text-gray-500">No path data available for this link.</div>;
}
return (
<div>
<div className="text-sm text-gray-500 mb-4">
Note: Paths are grouped by subpath. URLs with different query parameters but the same base path (without subpath) are grouped together.
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Clicks</th>
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Percentage</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pathData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<a
href="#"
className="hover:text-blue-600 hover:underline cursor-pointer"
onClick={(e) => handlePathClick(item.path, e)}
>
{item.path}
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{item.count}</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end">
<span className="text-sm text-gray-500 mr-2">{(item.percentage * 100).toFixed(1)}%</span>
<div className="w-32 bg-gray-200 rounded-full h-2.5">
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${item.percentage * 100}%` }}></div>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default PathAnalytics;

View File

@@ -0,0 +1,205 @@
"use client";
import { useState, useEffect } from 'react';
interface UtmData {
utm_value: string;
clicks: number;
visitors: number;
avg_time_spent: number;
bounces: number;
conversions: number;
}
interface UtmAnalyticsProps {
startTime?: string;
endTime?: string;
linkId?: string;
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
subpath?: string;
}
export default function UtmAnalytics({ startTime, endTime, linkId, teamIds, projectIds, tagIds, subpath }: UtmAnalyticsProps) {
const [activeTab, setActiveTab] = useState<string>('source');
const [utmData, setUtmData] = useState<UtmData[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// 加载UTM数据
useEffect(() => {
const fetchUtmData = async () => {
setIsLoading(true);
setError(null);
try {
// 构建URL参数
const params = new URLSearchParams();
if (startTime) params.append('startTime', startTime);
if (endTime) params.append('endTime', endTime);
if (linkId) params.append('linkId', linkId);
if (subpath) params.append('subpath', subpath);
params.append('utmType', activeTab);
// 添加团队ID参数
if (teamIds && teamIds.length > 0) {
teamIds.forEach(id => params.append('teamId', id));
}
// 添加项目ID参数
if (projectIds && projectIds.length > 0) {
projectIds.forEach(id => params.append('projectId', id));
}
// 添加标签名称参数
if (tagIds && tagIds.length > 0) {
tagIds.forEach(tagName => params.append('tagName', tagName));
}
// 发送请求
const response = await fetch(`/api/events/utm?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch UTM data');
}
const result = await response.json();
if (result.success) {
setUtmData(result.data || []);
} else {
throw new Error(result.error || 'Failed to fetch UTM data');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
console.error('Error fetching UTM data:', err);
} finally {
setIsLoading(false);
}
};
fetchUtmData();
}, [activeTab, startTime, endTime, linkId, teamIds, projectIds, tagIds, subpath]);
// 安全地格式化数字
const formatNumber = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
return value.toLocaleString();
};
return (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">UTM Parameters</h2>
<div className="mb-4 border-b">
<div className="flex">
<button
onClick={() => setActiveTab('source')}
className={`px-4 py-2 ${activeTab === 'source' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Source
</button>
<button
onClick={() => setActiveTab('medium')}
className={`px-4 py-2 ${activeTab === 'medium' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Medium
</button>
<button
onClick={() => setActiveTab('campaign')}
className={`px-4 py-2 ${activeTab === 'campaign' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Campaign
</button>
<button
onClick={() => setActiveTab('term')}
className={`px-4 py-2 ${activeTab === 'term' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Term
</button>
<button
onClick={() => setActiveTab('content')}
className={`px-4 py-2 ${activeTab === 'content' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Content
</button>
</div>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span className="ml-2 text-gray-500">Loading...</span>
</div>
) : error ? (
<div className="text-red-500 text-center py-8">
Error: {error}
</div>
) : utmData.length === 0 ? (
<div className="text-gray-500 text-center py-8">
No data available
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{activeTab === 'source' ? 'Source' :
activeTab === 'medium' ? 'Medium' :
activeTab === 'campaign' ? 'Campaign' :
activeTab === 'term' ? 'Term' : 'Content'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Clicks
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Avg. Time
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bounce Rate
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Conversions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{utmData.map((item, index) => {
const bounceRate = item.clicks > 0 ? (item.bounces / item.clicks) * 100 : 0;
const conversionRate = item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0;
return (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.utm_value || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.clicks)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.avg_time_spent.toFixed(1)}s
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{bounceRate.toFixed(1)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.conversions)} ({conversionRate.toFixed(1)}%)
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useEffect, useRef } from 'react';
import { DeviceAnalytics } from '@/app/api/types';
import { Chart, PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale } from 'chart.js';
// 注册Chart.js组件
Chart.register(PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale);
interface DevicePieChartsProps {
data: DeviceAnalytics;
}
// 颜色配置
const COLORS = {
deviceTypes: ['rgba(59, 130, 246, 0.8)', 'rgba(96, 165, 250, 0.8)', 'rgba(147, 197, 253, 0.8)', 'rgba(191, 219, 254, 0.8)', 'rgba(219, 234, 254, 0.8)'],
browsers: ['rgba(16, 185, 129, 0.8)', 'rgba(52, 211, 153, 0.8)', 'rgba(110, 231, 183, 0.8)', 'rgba(167, 243, 208, 0.8)', 'rgba(209, 250, 229, 0.8)'],
os: ['rgba(239, 68, 68, 0.8)', 'rgba(248, 113, 113, 0.8)', 'rgba(252, 165, 165, 0.8)', 'rgba(254, 202, 202, 0.8)', 'rgba(254, 226, 226, 0.8)']
};
export default function DevicePieCharts({ data }: DevicePieChartsProps) {
// 创建图表引用
const deviceTypesChartRef = useRef<HTMLCanvasElement>(null);
const browsersChartRef = useRef<HTMLCanvasElement>(null);
const osChartRef = useRef<HTMLCanvasElement>(null);
// 图表实例引用
const deviceTypesChartInstance = useRef<Chart | null>(null);
const browsersChartInstance = useRef<Chart | null>(null);
const osChartInstance = useRef<Chart | null>(null);
// 初始化和更新图表
useEffect(() => {
if (!data) return;
// 销毁旧的图表实例
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
// 创建设备类型图表
if (deviceTypesChartRef.current && data.deviceTypes.length > 0) {
const ctx = deviceTypesChartRef.current.getContext('2d');
if (ctx) {
deviceTypesChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.deviceTypes.map(item => item.type),
datasets: [{
data: data.deviceTypes.map(item => item.count),
backgroundColor: COLORS.deviceTypes,
borderColor: COLORS.deviceTypes.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建浏览器图表
if (browsersChartRef.current && data.browsers.length > 0) {
const ctx = browsersChartRef.current.getContext('2d');
if (ctx) {
browsersChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.browsers.map(item => item.name),
datasets: [{
data: data.browsers.map(item => item.count),
backgroundColor: COLORS.browsers,
borderColor: COLORS.browsers.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建操作系统图表
if (osChartRef.current && data.operatingSystems.length > 0) {
const ctx = osChartRef.current.getContext('2d');
if (ctx) {
osChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.operatingSystems.map(item => item.name),
datasets: [{
data: data.operatingSystems.map(item => item.count),
backgroundColor: COLORS.os,
borderColor: COLORS.os.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 清理函数
return () => {
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
};
}, [data]);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 设备类型 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Device Types</h3>
<div className="h-64">
<canvas ref={deviceTypesChartRef} />
</div>
</div>
{/* 浏览器 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Browsers</h3>
<div className="h-64">
<canvas ref={browsersChartRef} />
</div>
</div>
{/* 操作系统 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Operating Systems</h3>
<div className="h-64">
<canvas ref={osChartRef} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler,
ChartData,
ChartOptions,
TooltipItem
} from 'chart.js';
import { TimeSeriesData } from '@/app/api/types';
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler
);
interface TimeSeriesChartProps {
data: TimeSeriesData[];
}
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
const chartRef = useRef<HTMLCanvasElement | null>(null);
const chartInstance = useRef<ChartJS | null>(null);
useEffect(() => {
if (!chartRef.current) return;
// 销毁旧的图表实例
if (chartInstance.current) {
chartInstance.current.destroy();
}
const ctx = chartRef.current.getContext('2d');
if (!ctx) return;
// 准备数据
const labels = data.map(item => {
if (!item || !item.timestamp) return '';
const date = new Date(item.timestamp);
return date.toLocaleDateString();
});
const eventsData = data.map(item => {
if (!item || item.events === undefined || item.events === null) return 0;
return Number(item.events);
});
const visitorsData = data.map(item => {
if (!item || item.visitors === undefined || item.visitors === null) return 0;
return Number(item.visitors);
});
const conversionsData = data.map(item => {
if (!item || item.conversions === undefined || item.conversions === null) return 0;
return Number(item.conversions);
});
// 创建新的图表实例
chartInstance.current = new ChartJS(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Events',
data: eventsData,
borderColor: 'rgb(59, 130, 246)', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Visitors',
data: visitorsData,
borderColor: 'rgb(16, 185, 129)', // green-500
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Conversions',
data: conversionsData,
borderColor: 'rgb(239, 68, 68)', // red-500
backgroundColor: 'rgba(239, 68, 68, 0.1)',
tension: 0.4,
fill: true
}
]
} as ChartData<'line'>,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
color: 'rgb(156, 163, 175)' // gray-400
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgb(31, 41, 55)', // gray-800
titleColor: 'rgb(229, 231, 235)', // gray-200
bodyColor: 'rgb(229, 231, 235)', // gray-200
borderColor: 'rgb(75, 85, 99)', // gray-600
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
title: (items: TooltipItem<'line'>[]) => {
if (items.length > 0) {
const date = new Date(data[items[0].dataIndex].timestamp);
return date.toLocaleDateString();
}
return '';
},
label: (context) => {
const label = context.dataset.label || '';
const value = context.parsed.y;
return `${label}: ${Math.round(value)}`;
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: 'rgb(156, 163, 175)' // gray-400
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity
},
ticks: {
color: 'rgb(156, 163, 175)', // gray-400
callback: (value: number) => {
if (!value && value !== 0) return '';
if (value >= 1000) {
return `${Math.round(value / 1000)}k`;
}
return Math.round(value);
}
}
}
}
} as ChartOptions<'line'>
});
// 清理函数
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [data]);
return (
<canvas ref={chartRef} />
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState, useEffect } from 'react';
import { getLocationFromIP } from '@/app/utils/ipLocation';
interface LocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
}
export default function IpLocationTest() {
const [locationData, setLocationData] = useState<LocationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const testIp = "120.244.39.90";
useEffect(() => {
async function fetchLocation() {
try {
setLoading(true);
setError(null);
const data = await getLocationFromIP(testIp);
setLocationData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally {
setLoading(false);
}
}
fetchLocation();
}, []);
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">IP Location Test: {testIp}</h2>
{loading && (
<div className="flex items-center text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
Loading location data...
</div>
)}
{error && (
<div className="text-red-500">
Error: {error}
</div>
)}
{!loading && locationData && (
<div className="space-y-4">
<div>
<h3 className="font-medium">Location Data:</h3>
<pre className="mt-2 p-4 bg-gray-100 rounded overflow-auto">
{JSON.stringify(locationData, null, 2)}
</pre>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border p-3 rounded">
<h4 className="font-medium">Country</h4>
<div>{locationData.country_name} ({locationData.country_code})</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">City</h4>
<div>{locationData.city || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Region</h4>
<div>{locationData.region || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Continent</h4>
<div>{locationData.continent_name} ({locationData.continent_code})</div>
</div>
<div className="border p-3 rounded col-span-2">
<h4 className="font-medium">Coordinates</h4>
<div>Latitude: {locationData.latitude}, Longitude: {locationData.longitude}</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/lib/auth';
export default function Header() {
const { user, signOut } = useAuth();
const handleLogout = async () => {
await signOut();
};
return (
<header className="w-full py-4 border-b border-gray-200 bg-white">
<div className="container flex items-center justify-between px-4 mx-auto">
<div className="flex items-center space-x-4">
<Link href="/analytics" className="flex items-center space-x-2">
<svg
className="w-6 h-6 text-blue-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<span className="text-xl font-bold text-gray-900">ShortURL Analytics</span>
</Link>
{user && (
<nav className="ml-6">
<ul className="flex space-x-4">
<li>
<Link href="/analytics" className="text-sm text-gray-700 hover:text-blue-500">
Analytics
</Link>
</li>
<li>
<Link href="/links" className="text-sm text-gray-700 hover:text-blue-500">
Short Links
</Link>
</li>
</ul>
</nav>
)}
</div>
{user && (
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-700">
{user.email}
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-white bg-blue-500 rounded hover:bg-blue-600"
>
Logout
</button>
</div>
)}
</div>
</header>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
import ThemeToggle from "../ui/ThemeToggle";
export default function Navbar() {
return (
@@ -40,7 +39,6 @@ export default function Navbar() {
</nav>
</div>
<div className="flex items-center space-x-3">
<ThemeToggle />
<button className="p-2 text-sm text-foreground rounded-md gradient-border">
Upgrade
</button>

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from 'react';
import { format } from 'date-fns';
interface DateRange {
from: Date;
to: Date;
}
interface DateRangePickerProps {
value: DateRange;
onChange: (value: DateRange) => void;
className?: string;
}
export function DateRangePicker({
value,
onChange,
className
}: DateRangePickerProps) {
// Internal date state for validation
const [from, setFrom] = useState<string>(
value.from ? format(value.from, 'yyyy-MM-dd') : ''
);
const [to, setTo] = useState<string>(
value.to ? format(value.to, 'yyyy-MM-dd') : ''
);
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFrom = e.target.value;
setFrom(newFrom);
if (newFrom) {
onChange({
from: new Date(newFrom),
to: value.to
});
}
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTo = e.target.value;
setTo(newTo);
if (newTo) {
onChange({
from: value.from,
to: new Date(newTo)
});
}
};
return (
<div className={`flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4 ${className}`}>
<div>
<label htmlFor="from" className="block text-sm font-medium text-gray-500 mb-1">
Start Date
</label>
<input
type="date"
id="from"
value={from}
onChange={handleFromChange}
max={to}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="to" className="block text-sm font-medium text-gray-500 mb-1">
End Date
</label>
<input
type="date"
id="to"
value={to}
onChange={handleToChange}
min={from}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { Loader2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// Define our own Project type
interface Project {
id: string;
name: string;
description?: string | null;
attributes?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
schema_version?: number | null;
creator_id?: string | null;
team_name?: string;
}
// 添加需要的类型定义
interface ProjectWithTeam {
project_id: string;
projects: Project;
teams?: { name: string };
}
// ProjectSelector component with multi-select support
export function ProjectSelector({
value,
onChange,
className,
multiple = false,
teamId,
teamIds,
}: {
value?: string | string[];
onChange?: (projectId: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional team ID to filter projects by team
teamIds?: string[]; // Optional array of team IDs to filter projects by multiple teams
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Normalize team IDs to ensure we're always working with an array
const effectiveTeamIds = React.useMemo(() => {
if (teamIds && teamIds.length > 0) {
return teamIds;
} else if (teamId) {
return [teamId];
}
return undefined;
}, [teamId, teamIds]);
// Initialize selected projects based on value prop
useEffect(() => {
if (value) {
if (Array.isArray(value)) {
setSelectedIds(value);
} else {
setSelectedIds(value ? [value] : []);
}
} else {
setSelectedIds([]);
}
}, [value]);
// Add click outside listener to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
// Only add the event listener if the dropdown is open
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen]);
useEffect(() => {
let isMounted = true;
const fetchProjects = async (userId: string) => {
if (!isMounted) return;
setLoading(true);
setError(null);
try {
const supabase = getSupabaseClient();
if (effectiveTeamIds && effectiveTeamIds.length > 0) {
// If team IDs are provided, get projects for those teams
const { data: projectsData, error: projectsError } = await supabase
.from('team_projects')
.select('project_id, projects:project_id(*), teams:team_id(name)')
.in('team_id', effectiveTeamIds)
.is('projects.deleted_at', null);
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
if (isMounted) setProjects([]);
return;
}
// Extract projects from response with team info
if (isMounted) {
const projectList: Project[] = [];
for (const item of projectsData as ProjectWithTeam[]) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
if (item.teams && 'name' in item.teams) {
project.team_name = item.teams.name;
}
// Avoid duplicate projects from different teams
if (!projectList.some(p => p.id === project.id)) {
projectList.push(project);
}
}
}
setProjects(projectList);
}
} else {
// If no team IDs, get all user's projects
const { data: projectsData, error: projectsError } = await supabase
.from('user_projects')
.select('project_id, projects:project_id(*)')
.eq('user_id', userId)
.is('projects.deleted_at', null);
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
if (isMounted) setProjects([]);
return;
}
// Fetch team info for these projects
const projectIds = projectsData.map(item => item.project_id);
// Get team info for each project
const { data: teamProjectsData, error: teamProjectsError } = await supabase
.from('team_projects')
.select('project_id, teams:team_id(name)')
.in('project_id', projectIds);
if (teamProjectsError) throw teamProjectsError;
// Create project ID to team name mapping
const projectTeamMap: Record<string, string> = {};
if (teamProjectsData) {
teamProjectsData.forEach(item => {
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
projectTeamMap[item.project_id] = (item.teams as { name: string }).name;
}
});
}
// Extract projects with team names
if (isMounted && projectsData) {
const projectList: Project[] = [];
for (const item of projectsData) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
project.team_name = projectTeamMap[project.id];
projectList.push(project);
}
}
setProjects(projectList);
}
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load projects');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchProjects(session.user.id);
} else if (event === 'SIGNED_OUT') {
setProjects([]);
setError(null);
}
});
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user?.id) {
fetchProjects(session.user.id);
}
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && projects.length > 0) {
setIsOpen(!isOpen);
}
};
const handleProjectSelect = (projectId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle project in/out of selection
if (selectedIds.includes(projectId)) {
newSelected = selectedIds.filter(id => id !== projectId);
} else {
newSelected = [...selectedIds, projectId];
}
} else {
// For single-select: replace selection with the new project
newSelected = [projectId];
setIsOpen(false);
}
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
const removeProject = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== projectId);
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
if (loading) {
return (
<div className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2",
className
)}>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
if (error) {
return (
<div className={cn(
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
className
)}>
{error}
</div>
);
}
if (projects.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No projects available
</div>
);
}
const selectedProjects = projects.filter(project => selectedIds.includes(project.id));
return (
<div className="relative" ref={selectorRef}>
<div
className={cn(
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
isOpen && "ring-2 ring-offset-2 ring-blue-500",
className
)}
onClick={handleToggle}
>
{selectedProjects.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedProjects.map(project => (
<div
key={project.id}
className="flex items-center gap-1 bg-green-100 text-green-800 rounded-md px-2 py-1 text-sm"
>
{project.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-green-900"
onClick={(e) => removeProject(e, project.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select a project</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{projects.map(project => (
<div
key={project.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(project.id) && "bg-green-50"
)}
onClick={() => handleProjectSelect(project.id)}
>
<span className="flex flex-col">
<span className="font-medium">{project.name}</span>
{project.team_name && (
<span className="text-xs text-gray-500">
{project.team_name}
</span>
)}
{project.description && (
<span className="text-xs text-gray-500 truncate max-w-[250px]">
{project.description}
</span>
)}
</span>
{selectedIds.includes(project.id) && (
<Check className="h-4 w-4 text-green-600" />
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
interface SelectOption {
value: string;
label: string;
icon?: string;
}
interface SelectProps {
value?: string;
onChange?: (value: string) => void;
options: SelectOption[];
placeholder?: string;
className?: string;
}
export function Select({ value, onChange, options, placeholder, className = '' }: SelectProps) {
const [isOpen, setIsOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const selectedOption = options.find(option => option.value === value);
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="flex items-center">
{selectedOption?.icon && (
<img
src={selectedOption.icon}
alt=""
className="mr-2 h-4 w-4 rounded-full"
/>
)}
{selectedOption?.label || placeholder}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80">
<div className="p-1">
{options.map((option) => (
<button
key={option.value}
onClick={() => {
onChange?.(option.value);
setIsOpen(false);
}}
className={`relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground ${
option.value === value ? 'bg-accent text-accent-foreground' : ''
}`}
>
{option.icon && (
<img
src={option.icon}
alt=""
className="mr-2 h-4 w-4 rounded-full"
/>
)}
{option.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,321 @@
"use client";
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent } from '@supabase/supabase-js';
import { Loader2, X, Check, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
// Define Tag type based on the database schema
interface Tag {
id: string;
name: string;
type?: string | null;
attributes?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
parent_tag_id?: string | null;
team_id?: string | null;
is_shared?: boolean;
schema_version?: number | null;
is_system?: boolean;
}
// TagSelector component with multi-select support
export function TagSelector({
value,
onChange,
className,
multiple = false,
teamId,
teamIds,
}: {
value?: string | string[];
onChange?: (tagIds: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional single team ID
teamIds?: string[]; // Optional array of team IDs
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Normalize team IDs to ensure we're always working with an array
const effectiveTeamIds = React.useMemo(() => {
if (teamIds && teamIds.length > 0) {
return teamIds;
} else if (teamId) {
return [teamId];
}
return undefined;
}, [teamId, teamIds]);
// 标签名称与ID的映射函数
const getTagIdByName = (name: string): string | undefined => {
const tag = tags.find(t => t.name === name);
return tag?.id;
};
const getTagNameById = (id: string): string | undefined => {
const tag = tags.find(t => t.id === id);
return tag?.name;
};
// 从标签名称转换为标签ID
const nameToId = (nameOrNames: string | string[] | undefined): string[] => {
if (!nameOrNames) return [];
if (Array.isArray(nameOrNames)) {
return nameOrNames
.map(name => getTagIdByName(name))
.filter((id): id is string => !!id);
}
const id = getTagIdByName(nameOrNames);
return id ? [id] : [];
};
// 从标签ID转换为标签名称
const idToName = (idOrIds: string | string[] | undefined): string[] => {
if (!idOrIds) return [];
if (Array.isArray(idOrIds)) {
return idOrIds
.map(id => getTagNameById(id))
.filter((name): name is string => !!name);
}
const name = getTagNameById(idOrIds);
return name ? [name] : [];
};
// 初始化已选择的标签 - 从传入的名称转换为ID
useEffect(() => {
if (tags.length > 0 && value) {
setSelectedIds(nameToId(value));
}
}, [value, tags]);
// Add click outside listener to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
// Only add the event listener if the dropdown is open
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen]);
useEffect(() => {
let isMounted = true;
const fetchTags = async () => {
if (!isMounted) return;
setLoading(true);
setError(null);
try {
const supabase = getSupabaseClient();
let query = supabase.from('tags').select('*').is('deleted_at', null);
// Filter by team if teamId is provided
if (effectiveTeamIds) {
query = query.in('team_id', effectiveTeamIds);
}
const { data: tagsData, error: tagsError } = await query;
if (tagsError) throw tagsError;
if (!tagsData || tagsData.length === 0) {
if (isMounted) setTags([]);
return;
}
if (isMounted) {
setTags(tagsData as Tag[]);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load tags');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
if (event === 'SIGNED_IN') {
fetchTags();
} else if (event === 'SIGNED_OUT') {
setTags([]);
setError(null);
}
});
supabase.auth.getSession().then(() => {
fetchTags();
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && tags.length > 0) {
setIsOpen(!isOpen);
}
};
const handleTagSelect = (tagId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle tag in/out of selection
if (selectedIds.includes(tagId)) {
newSelected = selectedIds.filter(id => id !== tagId);
} else {
newSelected = [...selectedIds, tagId];
}
} else {
// For single-select: replace selection with the new tag
newSelected = [tagId];
setIsOpen(false);
}
setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) {
const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
}
};
const removeTag = (e: React.MouseEvent, tagId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== tagId);
setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) {
const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
}
};
if (loading) {
return (
<div className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2",
className
)}>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
if (error) {
return (
<div className={cn(
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
className
)}>
{error}
</div>
);
}
if (tags.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No tags available
</div>
);
}
// 根据已选择的ID筛选出已选择的标签
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
return (
<div className="relative" ref={selectorRef}>
<div
className={cn(
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
isOpen && "ring-2 ring-offset-2 ring-purple-500",
className
)}
onClick={handleToggle}
>
{selectedTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedTags.map(tag => (
<div
key={tag.id}
className="flex items-center gap-1 bg-purple-100 text-purple-800 rounded-md px-2 py-1 text-sm"
>
{tag.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-purple-900"
onClick={(e) => removeTag(e, tag.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select tags</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{tags.map(tag => (
<div
key={tag.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(tag.id) && "bg-purple-50"
)}
onClick={() => handleTagSelect(tag.id)}
>
<span className="flex items-center gap-2">
<Tag className="h-4 w-4 text-purple-500" />
<span>{tag.name}</span>
{tag.type && (
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{tag.type}
</span>
)}
</span>
{selectedIds.includes(tag.id) && (
<Check className="h-4 w-4 text-purple-600" />
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import type { Database } from '@/types/supabase';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { Loader2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
type Team = Database['limq']['Tables']['teams']['Row'];
// TeamSelector component with multi-select support
export function TeamSelector({
value,
onChange,
className,
multiple = false,
}: {
value?: string | string[];
onChange?: (teamId: string | string[]) => void;
className?: string;
multiple?: boolean;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Initialize selected teams based on value prop
useEffect(() => {
if (value) {
if (Array.isArray(value)) {
setSelectedIds(value);
} else {
setSelectedIds(value ? [value] : []);
}
} else {
setSelectedIds([]);
}
}, [value]);
// Add click outside listener to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
// Only add the event listener if the dropdown is open
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen]);
useEffect(() => {
let isMounted = true;
const fetchTeams = async (userId: string) => {
if (!isMounted) return;
setLoading(true);
setError(null);
try {
const supabase = getSupabaseClient();
const { data: memberships, error: membershipError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
if (membershipError) throw membershipError;
if (!memberships || memberships.length === 0) {
if (isMounted) setTeams([]);
return;
}
const teamIds = memberships.map(m => m.team_id);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
if (teamsError) throw teamsError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load teams');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchTeams(session.user.id);
} else if (event === 'SIGNED_OUT') {
setTeams([]);
setError(null);
}
});
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user?.id) {
fetchTeams(session.user.id);
}
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const handleToggle = () => {
if (!loading && !error && teams.length > 0) {
setIsOpen(!isOpen);
}
};
const handleTeamSelect = (teamId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle team in/out of selection
if (selectedIds.includes(teamId)) {
newSelected = selectedIds.filter(id => id !== teamId);
} else {
newSelected = [...selectedIds, teamId];
}
} else {
// For single-select: replace selection with the new team
newSelected = [teamId];
setIsOpen(false);
}
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
const removeTeam = (e: React.MouseEvent, teamId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== teamId);
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
if (loading) {
return (
<div className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2",
className
)}>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
if (error) {
return (
<div className={cn(
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
className
)}>
{error}
</div>
);
}
if (teams.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No teams available
</div>
);
}
const selectedTeams = teams.filter(team => selectedIds.includes(team.id));
return (
<div className="relative" ref={selectorRef}>
<div
className={cn(
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
isOpen && "ring-2 ring-offset-2 ring-blue-500",
className
)}
onClick={handleToggle}
>
{selectedTeams.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedTeams.map(team => (
<div
key={team.id}
className="flex items-center gap-1 bg-blue-100 text-blue-800 rounded-md px-2 py-1 text-sm"
>
{team.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-blue-900"
onClick={(e) => removeTeam(e, team.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select a team</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{teams.map(team => (
<div
key={team.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(team.id) && "bg-blue-50"
)}
onClick={() => handleTeamSelect(team.id)}
>
<span>{team.name}</span>
{selectedIds.includes(team.id) && (
<Check className="h-4 w-4 text-blue-600" />
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,64 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [darkMode, setDarkMode] = useState(false);
// Initialize theme on component mount
useEffect(() => {
const isDarkMode = localStorage.getItem('darkMode') === 'true';
setDarkMode(isDarkMode);
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);
// Update theme when darkMode state changes
const toggleTheme = () => {
const newDarkMode = !darkMode;
setDarkMode(newDarkMode);
localStorage.setItem('darkMode', newDarkMode.toString());
if (newDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
return (
<button
onClick={toggleTheme}
className="p-2 rounded-md bg-card-bg border border-card-border hover:bg-card-bg/80 transition-colors"
aria-label={darkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{darkMode ? (
<svg
className="w-5 h-5 text-accent-yellow"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-5 h-5 text-foreground"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
);
}

View File

@@ -30,30 +30,6 @@
--gradient-red: linear-gradient(135deg, #f43f5e, #e11d48);
}
.dark {
/* Dark Mode */
--background: #0f172a;
--foreground: #ffffff;
/* Card colors */
--card-bg: #1e293b;
--card-border: #334155;
/* Vibrant accent colors */
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-red: #f43f5e;
--accent-yellow: #f59e0b;
--accent-purple: #8b5cf6;
--accent-pink: #ec4899;
--accent-teal: #14b8a6;
--accent-orange: #f97316;
/* UI colors */
--text-secondary: #94a3b8;
--progress-bg: #334155;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View File

@@ -1,21 +1,13 @@
import './globals.css';
import '@radix-ui/themes/styles.css';
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google";
import Navbar from "./components/layout/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { AuthProvider } from '@/lib/auth';
import { Theme } from '@radix-ui/themes';
import Header from '@/app/components/layout/Header';
export const metadata: Metadata = {
title: 'ShortURL Analytics',
description: 'Analytics dashboard for short URL management',
description: 'Track and analyze shortened links',
};
export default function RootLayout({
@@ -25,13 +17,13 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
>
<Navbar />
<main className="min-h-screen px-4 py-6">
{children}
</main>
<body>
<Theme>
<AuthProvider>
<Header />
{children}
</AuthProvider>
</Theme>
</body>
</html>
);

View File

@@ -1,21 +0,0 @@
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Link Management & Analytics',
description: 'Track and analyze shortened links',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
{children}
</body>
</html>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

221
app/login/page.tsx Normal file
View File

@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth';
export default function LoginPage() {
const router = useRouter();
const { signIn, signInWithGitHub, signInWithGoogle, user } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', content: '' });
// 如果用户已登录,重定向到首页
useEffect(() => {
if (user) {
router.push('/');
}
}, [user, router]);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setMessage({
type: 'error',
content: 'Please enter both email and password'
});
return;
}
try {
setIsLoading(true);
setMessage({ type: '', content: '' });
const { error } = await signIn(email, password);
if (error) {
throw new Error(error.message);
}
// 登录成功后,会通过 useEffect 重定向
} catch (error) {
console.error('Login error:', error);
setMessage({
type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in'
});
setIsLoading(false);
}
};
const handleGitHubSignIn = async () => {
try {
setIsLoading(true);
setMessage({ type: '', content: '' });
const { error } = await signInWithGitHub();
if (error) {
throw new Error(error.message);
}
// 登录成功后,会通过 useEffect 重定向
} catch (error) {
console.error('GitHub login error:', error);
setMessage({
type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in with GitHub'
});
setIsLoading(false);
}
};
const handleGoogleSignIn = async () => {
try {
setIsLoading(true);
setMessage({ type: '', content: '' });
const { error } = await signInWithGoogle();
if (error) {
throw new Error(error.message);
}
// 登录成功后,会通过 useEffect 重定向
} catch (error) {
console.error('Google login error:', error);
setMessage({
type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in with Google'
});
setIsLoading(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900">Login</h1>
<p className="mt-2 text-sm text-gray-600">
Sign in to your account to access analytics
</p>
<div className="mt-2 text-xs text-gray-500">
Welcome to ShortURL Analytics
</div>
</div>
{/* Message display */}
{message.content && (
<div className={`p-4 mb-4 text-sm ${
message.type === 'error'
? 'text-red-700 bg-red-100 rounded-lg'
: 'text-blue-700 bg-blue-100 rounded-lg'
}`}>
{message.content}
</div>
)}
<form onSubmit={handleEmailSignIn} className="mt-8 space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="your@email.com"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="••••••••"
disabled={isLoading}
/>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleGitHubSignIn}
disabled={isLoading}
className="flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
GitHub
</button>
<button
type="button"
onClick={handleGoogleSignIn}
disabled={isLoading}
className="flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
<path d="M12.545 12.151L12.545 12.151L12.545 12.151C12.545 9.85553 14.0905 7.98375 16.088 7.98375C17.0865 7.98375 17.938 8.43025 18.5592 9.0514L21.3404 6.27019C19.7172 4.75612 18.0026 4 16.088 4C12.5405 4 9.5 6.67528 9.5 10.2505C9.5 12.0582 10.1533 13.4581 10.8634 14.4685C12.1453 16.3618 14.4737 18.501 16.088 18.501C19.9265 18.501 22 16.0057 22 12.4071C22 11.4245 21.9318 10.9113 21.7953 10.2505H16.088V12.151H12.545Z" fill="#4285F4" />
<path d="M5.90607 10.2197C5.40834 11.1993 5.12343 12.2959 5.12343 13.4564C5.12343 14.6646 5.41958 15.782 5.92853 16.7831L5.92786 16.7818C6.91998 18.6136 8.81431 19.8018 11.0008 19.8018C12.5581 19.8018 13.8262 19.318 14.7997 18.5825L14.7976 18.5845C15.6806 17.9139 16.401 16.9218 16.6662 15.7257L16.6657 15.7276C16.7331 15.3933 16.7688 15.0493 16.7688 14.6895H11.0008C10.3375 14.6895 9.80078 14.1523 9.80078 13.4882V10.2197H5.90607Z" fill="#34A853" />
<path d="M5.12207 6.25024C4 7.86024 3.33789 9.81535 3.33789 11.9339C3.33789 12.9995 3.55215 14.0269 3.94853 14.9805L5.90673 10.2197H9.80143V6.25024H5.12207Z" fill="#FBBC05" />
<path d="M11.001 3.57764C12.4571 3.57764 13.778 4.11181 14.8023 5.06959L14.8028 5.0692L17.2711 2.60092L17.271 2.60082C15.5041 0.97625 13.3649 0 11.001 0C8.81453 0 6.91994 1.18824 5.92853 3.02125L9.80224 6.25031V6.25031H11.001V3.57764Z" fill="#EA4335" />
</svg>
Google
</button>
</div>
</div>
<p className="text-sm text-gray-600">
Don&apos;t have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
Register
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/links');
redirect('/analytics');
}

195
app/register/page.tsx Normal file
View File

@@ -0,0 +1,195 @@
'use client';
import { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useAuth } from '@/lib/auth';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { signUp, signInWithGoogle } = useAuth();
// 处理注册表单提交
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
// 验证密码
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
// 密码强度验证
if (password.length < 6) {
setError('密码长度至少为6个字符');
return;
}
setIsLoading(true);
try {
await signUp(email, password);
// 注册成功后会跳转到登录页面,提示用户验证邮箱
} catch (error) {
console.error('Registration error:', error);
setError('注册失败,请稍后再试或使用其他邮箱');
} finally {
setIsLoading(false);
}
};
// 处理Google注册/登录
const handleGoogleSignIn = async () => {
setError(null);
try {
await signInWithGoogle();
// 登录流程会重定向到Google然后回到应用
} catch (error) {
console.error('Google sign in error:', error);
setError('Google登录失败请稍后再试');
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"></h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
访
</p>
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 mb-4 text-sm text-red-700 bg-red-100 dark:bg-red-900 dark:text-red-200 rounded-lg">
{error}
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="your@email.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="********"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="********"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '注册中...' : '注册'}
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400"></span>
</div>
</div>
<div>
<button
type="button"
onClick={handleGoogleSignIn}
className="w-full flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg
className="h-5 w-5 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
>
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</svg>
使Google账号注册
</button>
</div>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
</Link>
</p>
</div>
</div>
</div>
);
}

484
app/utils/ipLocation.ts Normal file
View File

@@ -0,0 +1,484 @@
interface IpLocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
timestamp?: number; // When this data was fetched
}
// In-memory cache
let locationCache: Record<string, IpLocationData> = {};
// Blacklist for IPs that failed to resolve multiple times
let failedIPs: Record<string, { attempts: number, lastAttempt: number }> = {};
// Cache expiration time (30 days in milliseconds)
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000;
// Max retries for a failed IP
const MAX_RETRY_ATTEMPTS = 3;
// Retry timeout (24 hours in milliseconds)
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
// Max number of IPs to batch in a single request
const MAX_BATCH_SIZE = 10;
/**
* Initialize cache from localStorage
*/
const initializeCache = () => {
if (typeof window === 'undefined') return;
try {
// Load location cache
const cachedData = localStorage.getItem('ip-location-cache');
if (cachedData) {
const parsedCache = JSON.parse(cachedData);
// Filter out expired entries
const now = Date.now();
const validEntries = Object.entries(parsedCache).filter(([, data]) => {
const entry = data as IpLocationData;
return entry.timestamp && now - entry.timestamp < CACHE_EXPIRATION;
});
locationCache = Object.fromEntries(validEntries) as Record<string, IpLocationData>;
console.log(`Loaded ${validEntries.length} IP locations from cache`);
}
// Load failed IPs
const failedIPsData = localStorage.getItem('ip-location-blacklist');
if (failedIPsData) {
const parsedFailedIPs = JSON.parse(failedIPsData);
// Filter out expired blacklist entries
const now = Date.now();
const validFailedEntries = Object.entries(parsedFailedIPs).filter(([, data]) => {
const entry = data as { attempts: number, lastAttempt: number };
// Keep entries that have max attempts or haven't timed out yet
return entry.attempts >= MAX_RETRY_ATTEMPTS ||
now - entry.lastAttempt < RETRY_TIMEOUT;
});
failedIPs = Object.fromEntries(validFailedEntries) as Record<string, { attempts: number, lastAttempt: number }>;
console.log(`Loaded ${validFailedEntries.length} blacklisted IPs`);
}
} catch (error) {
console.error('Failed to load IP location cache:', error);
// Reset cache if corrupted
localStorage.removeItem('ip-location-cache');
localStorage.removeItem('ip-location-blacklist');
locationCache = {};
failedIPs = {};
}
};
/**
* Save cache to localStorage
*/
const saveCache = () => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
} catch (error) {
console.error('Failed to save IP location cache:', error);
// If localStorage is full, clear old entries
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
// Clear older entries - keep newest 100
const entries = Object.entries(locationCache)
.sort((a, b) => {
const timestampA = (a[1].timestamp || 0);
const timestampB = (b[1].timestamp || 0);
return timestampB - timestampA;
})
.slice(0, 100);
locationCache = Object.fromEntries(entries);
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
}
}
};
/**
* Save failed IPs to localStorage
*/
const saveFailedIPs = () => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
} catch (error) {
console.error('Failed to save IP blacklist:', error);
// If localStorage is full, limit the size
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
// Keep only IPs with max attempts
const entries = Object.entries(failedIPs)
.filter(([, data]) => data.attempts >= MAX_RETRY_ATTEMPTS);
failedIPs = Object.fromEntries(entries);
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
}
}
};
/**
* Check if IP is a private/local address
*/
const isPrivateIP = (ip: string): boolean => {
return (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('127.') ||
ip === 'localhost' ||
ip === '::1'
);
};
/**
* Check if an IP should be skipped (blacklisted)
*/
const shouldSkipIP = (ip: string): boolean => {
// If not in failed list, don't skip
if (!failedIPs[ip]) return false;
const now = Date.now();
// If reached max attempts, skip
if (failedIPs[ip].attempts >= MAX_RETRY_ATTEMPTS) {
return true;
}
// If hasn't been long enough since last attempt, skip
if (now - failedIPs[ip].lastAttempt < RETRY_TIMEOUT) {
return true;
}
// Otherwise, we can try again
return false;
};
/**
* Mark IP as failed
*/
const markIPAsFailed = (ip: string): void => {
const now = Date.now();
if (failedIPs[ip]) {
failedIPs[ip] = {
attempts: failedIPs[ip].attempts + 1,
lastAttempt: now
};
} else {
failedIPs[ip] = {
attempts: 1,
lastAttempt: now
};
}
saveFailedIPs();
};
/**
* Get location data for a single IP address
*/
const fetchSingleIP = async (ip: string): Promise<IpLocationData | null> => {
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
return null;
}
try {
const response = await fetch(`https://ipapi.co/${ip}/json/`);
if (!response.ok) {
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
markIPAsFailed(ip);
return null;
}
const data = await response.json();
if (data.error) {
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
markIPAsFailed(ip);
return null;
}
// Reset failed attempts if successful
if (failedIPs[ip]) {
delete failedIPs[ip];
saveFailedIPs();
}
const locationData: IpLocationData = {
ip: data.ip,
country_name: data.country_name || 'Unknown',
country_code: data.country_code || 'UN',
city: data.city || 'Unknown',
region: data.region || 'Unknown',
continent_code: data.continent_code || 'UN',
continent_name: getContinentName(data.continent_code) || 'Unknown',
latitude: data.latitude || 0,
longitude: data.longitude || 0,
timestamp: Date.now()
};
return locationData;
} catch (error) {
console.error(`Error fetching location for IP ${ip}:`, error);
markIPAsFailed(ip);
return null;
}
};
/**
* Batch process multiple IPs at once using our own API endpoint
* This is a placeholder - we'll create a server API route for this
*/
const fetchBatchIPs = async (ips: string[]): Promise<Record<string, IpLocationData | null>> => {
try {
// Filter out blacklisted IPs
const validIPs = ips.filter(ip => !shouldSkipIP(ip));
if (validIPs.length === 0) {
return {};
}
const response = await fetch('/api/geo/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ips: validIPs }),
});
if (!response.ok) {
throw new Error(`Batch request failed: ${response.statusText}`);
}
const results = await response.json();
// Mark failed IPs from results
for (const [ip, data] of Object.entries(results.data)) {
if (!data) {
markIPAsFailed(ip);
} else if (failedIPs[ip]) {
// Reset failed attempts if successful
delete failedIPs[ip];
}
}
saveFailedIPs();
return results.data;
} catch (error) {
console.error('Error in batch IP lookup:', error);
// Fallback to individual requests
const results: Record<string, IpLocationData | null> = {};
for (const ip of ips) {
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
results[ip] = null;
continue;
}
// Add delays between requests to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
results[ip] = await fetchSingleIP(ip);
}
return results;
}
};
/**
* Handle private IP addresses
*/
const getPrivateIPData = (ip: string): IpLocationData => ({
ip,
country_name: 'Local Network',
country_code: 'LO',
city: 'Local',
region: 'Local',
continent_code: 'LO',
continent_name: 'Local',
latitude: 0,
longitude: 0,
timestamp: Date.now()
});
/**
* Convert an IP address to location information
* Individual lookup for a single IP
*/
export async function getLocationFromIP(ip: string): Promise<IpLocationData | null> {
// Initialize cache from localStorage if needed
if (Object.keys(locationCache).length === 0) {
initializeCache();
}
// Handle private IP addresses
if (isPrivateIP(ip)) {
const privateIPData = getPrivateIPData(ip);
locationCache[ip] = privateIPData;
return privateIPData;
}
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
return null;
}
// Return from cache if available and not expired
if (locationCache[ip]) {
const cachedData = locationCache[ip];
const now = Date.now();
// Return cached data if not expired
if (cachedData.timestamp && now - cachedData.timestamp < CACHE_EXPIRATION) {
return cachedData;
}
}
// Fetch new data
const locationData = await fetchSingleIP(ip);
// Save to cache if successful
if (locationData) {
locationCache[ip] = locationData;
saveCache();
}
return locationData;
}
/**
* Batch lookup for multiple IPs at once
* More efficient than calling getLocationFromIP multiple times
*/
export async function getLocationsFromIPs(ips: string[]): Promise<Record<string, IpLocationData | null>> {
// Initialize cache from localStorage if needed
if (Object.keys(locationCache).length === 0) {
initializeCache();
}
// Filter out IPs that are already in cache and not expired
const now = Date.now();
const cachedResults: Record<string, IpLocationData> = {};
const ipsToFetch: string[] = [];
for (const ip of ips) {
// Handle private IPs
if (isPrivateIP(ip)) {
cachedResults[ip] = getPrivateIPData(ip);
continue;
}
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
continue;
}
// Check cache
if (locationCache[ip] && locationCache[ip].timestamp &&
now - locationCache[ip].timestamp < CACHE_EXPIRATION) {
cachedResults[ip] = locationCache[ip];
} else {
ipsToFetch.push(ip);
}
}
// If all IPs were cached or blacklisted, return immediately
if (ipsToFetch.length === 0) {
return cachedResults;
}
// Process IPs in batches to avoid overwhelming the API
const results: Record<string, IpLocationData | null> = { ...cachedResults };
// Process in smaller batches (e.g., 10 IPs at a time)
for (let i = 0; i < ipsToFetch.length; i += MAX_BATCH_SIZE) {
const batchIPs = ipsToFetch.slice(i, i + MAX_BATCH_SIZE);
// Batch request
const batchResults = await fetchBatchIPs(batchIPs);
// Update results and cache
for (const [ip, data] of Object.entries(batchResults)) {
results[ip] = data;
if (data) {
locationCache[ip] = data;
}
}
// Save updated cache
saveCache();
// Add delay between batches
if (i + MAX_BATCH_SIZE < ipsToFetch.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
/**
* Get continent name from continent code
*/
function getContinentName(code?: string): string {
if (!code) return 'Unknown';
const continents: Record<string, string> = {
'AF': 'Africa',
'AN': 'Antarctica',
'AS': 'Asia',
'EU': 'Europe',
'NA': 'North America',
'OC': 'Oceania',
'SA': 'South America'
};
return continents[code] || 'Unknown';
}
/**
* Get location information based on view mode
*/
export function getLocationByType(
locationData: IpLocationData | null,
viewMode: 'country' | 'city' | 'region' | 'continent'
): string {
if (!locationData) return 'Unknown';
switch (viewMode) {
case 'country':
return locationData.country_name || 'Unknown';
case 'city':
return locationData.city || 'Unknown';
case 'region':
return locationData.region || 'Unknown';
case 'continent':
return locationData.continent_name || 'Unknown';
default:
return 'Unknown';
}
}

52
app/utils/store.ts Normal file
View File

@@ -0,0 +1,52 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Define interface for team, project and tag objects
interface TeamData {
team_id: string;
team_name: string;
[key: string]: unknown;
}
interface ProjectData {
project_id: string;
project_name: string;
[key: string]: unknown;
}
// 定义 ShortUrl 数据类型
export interface ShortUrlData {
id: string;
externalId: string;
slug: string;
originalUrl: string;
title?: string;
shortUrl: string;
teams?: TeamData[];
projects?: ProjectData[];
tags?: string[];
createdAt?: string;
domain?: string;
}
// 定义 store 类型
interface ShortUrlStore {
selectedShortUrl: ShortUrlData | null;
setSelectedShortUrl: (shortUrl: ShortUrlData | null) => void;
clearSelectedShortUrl: () => void;
}
// 创建 store 并使用 persist 中间件保存到 localStorage
export const useShortUrlStore = create<ShortUrlStore>()(
persist(
(set) => ({
selectedShortUrl: null,
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
}),
{
name: 'shorturl-storage', // localStorage 中的 key 名称
partialize: (state) => ({ selectedShortUrl: state.selectedShortUrl }), // 只持久化 selectedShortUrl
}
)
);

59
app/utils/supabase.ts Normal file
View File

@@ -0,0 +1,59 @@
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/types/supabase";
let supabase: SupabaseClient<Database> | null = null;
// 简单的存储适配器使用localStorage
const storageAdapter = {
getItem: async (key: string) => {
try {
const item = localStorage.getItem(key);
return item;
} catch (error) {
console.error("Storage get error:", error);
return null;
}
},
setItem: async (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch (error) {
console.error("Storage set error:", error);
}
},
removeItem: async (key: string) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error("Storage remove error:", error);
}
},
};
export const getSupabaseClient = (): SupabaseClient<Database> => {
if (!supabase) {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
throw new Error('Missing Supabase environment variables');
}
supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
db: { schema: "limq" },
auth: {
storage: storageAdapter,
persistSession: true,
autoRefreshToken: true,
},
}
);
}
return supabase;
};
export const clearSupabaseInstance = () => {
supabase = null;
};

151
docs/swagger-setup.md Normal file
View File

@@ -0,0 +1,151 @@
# Setting up Swagger UI in Next.js
This guide explains how to set up Swagger UI in a Next.js application using route groups.
## Directory Structure
The recommended directory structure for Swagger documentation:
```
app/
(swagger)/ # Route group for swagger-related pages
swagger/ # Actual swagger route
page.tsx # Swagger UI component
```
## Installation
1. Add Swagger UI dependencies to your project:
```json
{
"dependencies": {
"swagger-ui-react": "^5.12.0",
"swagger-ui-dist": "^5.12.0"
},
"devDependencies": {
"@types/swagger-ui-react": "^4.18.3"
}
}
```
2. Install webpack style loaders for handling Swagger UI CSS:
```bash
pnpm add -D style-loader css-loader
```
## Next.js Configuration
Create or update `next.config.js` to handle Swagger UI CSS:
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['swagger-ui-react'],
webpack: (config) => {
config.module.rules.push({
test: /\.css$/,
use: ['style-loader', 'css-loader'],
});
return config;
},
};
module.exports = nextConfig;
```
## Swagger UI Component
Create `app/(swagger)/swagger/page.tsx`:
```typescript
"use client";
import { useEffect } from 'react';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
export default function SwaggerPage() {
useEffect(() => {
document.title = 'API Documentation - ShortURL Analytics';
}, []);
const swaggerConfig = {
openapi: '3.0.0',
info: {
title: 'Your API Title',
version: '1.0.0',
description: 'API documentation',
contact: {
name: 'API Support',
email: 'support@example.com',
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
// ... your API configuration
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">API Documentation</h1>
<p className="text-gray-600">
Explore and test the API endpoints using the interactive documentation below.
</p>
</div>
<SwaggerUI spec={swaggerConfig} />
</div>
);
}
```
## Best Practices
1. **Route Groups**: Use route groups `(groupname)` to organize related pages without affecting the URL structure.
2. **API Documentation**:
- Add detailed descriptions for all endpoints
- Include parameter descriptions and constraints
- Define response schemas
- Document error responses
- Use appropriate data formats (UUID, URI, etc.)
- Group related endpoints using tags
3. **Swagger Configuration**:
- Add contact information
- Include license details
- Set appropriate servers configuration
- Define required fields
- Add parameter validations (min/max values)
## Common Issues
1. **Route Conflicts**: Avoid parallel routes that resolve to the same path. For example, don't have both `app/swagger/page.tsx` and `app/(group)/swagger/page.tsx` as they would conflict.
2. **CSS Loading**: Make sure to:
- Import Swagger UI CSS
- Configure webpack in `next.config.js`
- Use the `"use client"` directive as Swagger UI is a client-side component
3. **React Version Compatibility**: Be aware of potential peer dependency warnings between Swagger UI React and your React version. You might need to use `--legacy-peer-deps` or adjust your React version accordingly.
## Accessing the Documentation
After setup, your Swagger documentation will be available at `/swagger` in your application. The UI provides:
- Interactive API documentation
- Request/response examples
- Try-it-out functionality
- Schema definitions
- Error responses
## Maintenance
Keep your Swagger documentation up-to-date by:
- Updating the OpenAPI specification when adding or modifying endpoints
- Maintaining accurate parameter descriptions
- Keeping example values relevant
- Updating response schemas when data structures change

File diff suppressed because it is too large Load Diff

287
lib/auth.tsx Normal file
View File

@@ -0,0 +1,287 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Session, User } from '@supabase/supabase-js';
import supabase from './supabase';
// 定义用户类型
export type AuthUser = User | null;
// 定义验证上下文类型
export type AuthContextType = {
user: AuthUser;
session: Session | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<{ error?: any }>;
signInWithGoogle: () => Promise<{ error?: any }>;
signInWithGitHub: () => Promise<{ error?: any }>;
signUp: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
autoRegisterTestUser: () => Promise<void>; // 添加自动注册测试用户函数
};
// 创建验证上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 测试账户常量 - 使用已验证的账户
const TEST_EMAIL = 'vitalitymailg@gmail.com';
const TEST_PASSWORD = 'password123';
// 验证提供者组件
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// 初始化验证状态
useEffect(() => {
const getSession = async () => {
setIsLoading(true);
try {
// 尝试从Supabase获取会话
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('Error getting session:', error);
return;
}
setSession(session);
setUser(session?.user || null);
} catch (error) {
console.error('Unexpected error during getSession:', error);
} finally {
setIsLoading(false);
}
};
getSession();
// 监听验证状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user || null);
});
// 清理函数
return () => {
subscription.unsubscribe();
};
}, []);
// 登录函数
const signIn = async (email: string, password: string) => {
setIsLoading(true);
try {
console.log('尝试登录:', { email });
// 尝试通过Supabase登录
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error('登录出错:', error);
return { error };
}
setSession(data.session);
setUser(data.user);
router.push('/dashboard');
return {};
} catch (error) {
console.error('登录过程出错:', error);
return { error };
} finally {
setIsLoading(false);
}
};
// Google登录函数
const signInWithGoogle = async () => {
setIsLoading(true);
try {
// 尝试通过Supabase登录Google
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
console.error('Google登录出错:', error);
return { error };
}
return {}; // Return empty object when successful
} catch (error) {
console.error('Google登录过程出错:', error);
return { error };
} finally {
setIsLoading(false);
}
};
// GitHub登录函数
const signInWithGitHub = async () => {
setIsLoading(true);
try {
// 尝试通过Supabase登录GitHub
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
console.error('GitHub login error:', error);
return { error };
}
return {}; // Return empty object when successful
} catch (error) {
console.error('GitHub login process error:', error);
return { error };
} finally {
setIsLoading(false);
}
};
// 注册函数
const signUp = async (email: string, password: string) => {
setIsLoading(true);
try {
// 尝试通过Supabase注册
const { error } = await supabase.auth.signUp({
email,
password,
});
if (error) {
console.error('注册出错:', error);
throw error;
}
// 注册成功后跳转到登录页面并显示确认消息
router.push('/login?message=注册成功,请查看邮箱确认账户');
} catch (error) {
console.error('注册过程出错:', error);
throw error;
} finally {
setIsLoading(false);
}
};
// 登出函数
const signOut = async () => {
setIsLoading(true);
try {
// 尝试通过Supabase登出
const { error } = await supabase.auth.signOut();
if (error) {
console.error('登出出错:', error);
throw error;
}
setSession(null);
setUser(null);
router.push('/login');
} catch (error) {
console.error('登出过程出错:', error);
throw error;
} finally {
setIsLoading(false);
}
};
// 自动注册测试用户函数
const autoRegisterTestUser = async () => {
setIsLoading(true);
try {
console.log('正在使用测试账户登录:', TEST_EMAIL);
// 使用测试账户直接登录
const { data, error } = await supabase.auth.signInWithPassword({
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (error) {
console.error('测试账户登录失败:', error);
throw error;
}
console.log('测试账户登录成功!');
setSession(data.session);
setUser(data.user);
router.push('/dashboard');
} catch (error) {
console.error('测试账户登录出错:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const contextValue: AuthContextType = {
user,
session,
isLoading,
signIn,
signInWithGoogle,
signInWithGitHub,
signUp,
signOut,
autoRegisterTestUser,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};
// 自定义钩子
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// 受保护路由组件
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push('/login');
}
}, [user, isLoading, router]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">...</p>
</div>
</div>
);
}
if (!user) {
return null;
}
return <>{children}</>;
};
export default AuthContext;

View File

@@ -1,39 +1,165 @@
import { createClient } from '@clickhouse/client';
import { EventsQueryParams } from './analytics';
// Create configuration object using the URL approach
const config = {
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
username: process.env.CLICKHOUSE_USER || 'default',
password: process.env.CLICKHOUSE_PASSWORD || '',
database: process.env.CLICKHOUSE_DATABASE || 'limq'
};
// ClickHouse 客户端配置
const clickhouse = createClient({
url: process.env.CLICKHOUSE_URL,
username: process.env.CLICKHOUSE_USER ,
password: process.env.CLICKHOUSE_PASSWORD ,
database: process.env.CLICKHOUSE_DATABASE
});
// Create ClickHouse client with proper URL format
export const clickhouse = createClient(config);
// 构建日期过滤条件
function buildDateFilter(startTime?: string, endTime?: string): string {
const filters = [];
if (startTime) {
filters.push(`event_time >= parseDateTimeBestEffort('${startTime}')`);
}
/**
* Execute ClickHouse query and return results
*/
export async function executeQuery<T>(query: string): Promise<T[]> {
if (endTime) {
filters.push(`event_time <= parseDateTimeBestEffort('${endTime}')`);
}
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
}
// 构建通用过滤条件
export function buildFilter(params: Partial<EventsQueryParams>): string {
console.log('buildFilter received params:', JSON.stringify(params));
const filters = [];
// 添加日期过滤条件
if (params.startTime || params.endTime) {
const dateFilter = buildDateFilter(params.startTime, params.endTime);
if (dateFilter) {
filters.push(dateFilter.replace('WHERE ', ''));
}
}
// 添加事件类型过滤条件
if (params.eventType) {
filters.push(`event_type = '${params.eventType}'`);
}
// 添加链接ID过滤条件
if (params.linkId) {
console.log('Adding link_id filter:', params.linkId);
filters.push(`link_id = '${params.linkId}'`);
}
// 添加链接Slug过滤条件
if (params.linkSlug) {
filters.push(`link_slug = '${params.linkSlug}'`);
}
// 添加用户ID过滤条件
if (params.userId) {
filters.push(`user_id = '${params.userId}'`);
}
// 添加子路径过滤条件 - 使用更精确的匹配方式
if (params.subpath && params.subpath.trim() !== '') {
console.log('====== SUBPATH DEBUG ======');
console.log('Raw subpath param:', params.subpath);
// 清理并准备subpath值
let cleanSubpath = params.subpath.trim();
// 移除开头的斜杠以便匹配
if (cleanSubpath.startsWith('/')) {
cleanSubpath = cleanSubpath.substring(1);
}
// 移除结尾的斜杠以便匹配
if (cleanSubpath.endsWith('/')) {
cleanSubpath = cleanSubpath.substring(0, cleanSubpath.length - 1);
}
console.log('Cleaned subpath:', cleanSubpath);
// 使用正则表达式匹配URL中的第二个路径部分
// 示例: 在 "https://abc.com/slug/subpath/" 中匹配 "subpath"
const condition = `match(JSONExtractString(event_attributes, 'full_url'), '/[^/]+/${cleanSubpath}(/|\\\\?|$)')`;
console.log('Final SQL condition:', condition);
console.log('==========================');
filters.push(condition);
}
// 添加团队ID过滤条件
if (params.teamId) {
filters.push(`team_id = '${params.teamId}'`);
}
// 处理多个团队ID
if (params.teamIds && params.teamIds.length > 0) {
filters.push(`team_id IN (${params.teamIds.map(id => `'${id}'`).join(', ')})`);
}
// 添加项目ID过滤条件
if (params.projectId) {
filters.push(`project_id = '${params.projectId}'`);
}
// 处理多个项目ID
if (params.projectIds && params.projectIds.length > 0) {
filters.push(`project_id IN (${params.projectIds.map(id => `'${id}'`).join(', ')})`);
}
// 处理标签过滤 - 使用LIKE来匹配标签字符串
if (params.tagIds && params.tagIds.length > 0) {
const tagConditions = params.tagIds.map(tag =>
`link_tags LIKE '%${tag}%'`
);
filters.push(`(${tagConditions.join(' OR ')})`);
}
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
}
// 构建分页条件
export function buildPagination(page: number = 1, pageSize: number = 20): string {
const offset = (page - 1) * pageSize;
return `LIMIT ${pageSize} OFFSET ${offset}`;
}
// 构建排序条件
export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
return `ORDER BY ${sortBy} ${sortOrder}`;
}
// 执行查询
export async function executeQuery(query: string) {
console.log('Executing query:', query); // 查询日志
try {
const result = await clickhouse.query({
const resultSet = await clickhouse.query({
query,
format: 'JSONEachRow',
});
const data = await result.json();
return data as T[];
const rows = await resultSet.json();
return rows;
} catch (error) {
console.error('ClickHouse query error:', error);
console.error('查询执行错误:', error);
throw error;
}
}
/**
* Execute ClickHouse query and return a single result
*/
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
const results = await executeQuery<T>(query);
return results.length > 0 ? results[0] : null;
// 执行返回单一结果的查询
export async function executeQuerySingle(query: string) {
console.log('Executing single result query:', query); // 查询日志
try {
const resultSet = await clickhouse.query({
query,
format: 'JSONEachRow',
});
const rows = await resultSet.json();
return rows.length > 0 ? rows[0] : null;
} catch (error) {
console.error('单一结果查询执行错误:', error);
throw error;
}
}
export default clickhouse;

65
lib/supabase.ts Normal file
View File

@@ -0,0 +1,65 @@
import { createClient } from '@supabase/supabase-js';
// 从环境变量获取Supabase配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || '';
console.log('Supabase Configuration Check:', {
urlDefined: !!supabaseUrl,
keyDefined: !!supabaseAnonKey,
url: supabaseUrl,
// 打印部分key以便调试
keyPrefix: supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'undefined',
keyLength: supabaseAnonKey ? supabaseAnonKey.length : 0
});
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Supabase URL and Anon Key are required');
}
// 尝试解码JWT token并打印解码内容
try {
if (supabaseAnonKey) {
const parts = supabaseAnonKey.split('.');
if (parts.length === 3) {
const payload = parts[1];
const decoded = atob(payload);
console.log('JWT Payload:', decoded);
} else {
console.error('Invalid JWT format, expected 3 parts but got:', parts.length);
}
}
} catch (error) {
console.error('JWT解码失败:', error);
}
// 创建Supabase客户端
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
}
});
// 测试Supabase连接
supabase.auth.onAuthStateChange((event, session) => {
console.log(`Supabase auth event: ${event}`, session ? 'Session exists' : 'No session');
if (session) {
console.log('Current user:', session.user.email);
}
});
// 尝试执行健康检查
async function checkSupabaseHealth() {
try {
const { data, error } = await supabase.from('_health').select('*').limit(1);
console.log('Supabase health check:', error ? `Error: ${error.message}` : 'Success', data);
} catch (error) {
console.error('Supabase health check error:', error);
}
}
checkSupabaseHealth();
export default supabase;

184
lib/types.ts Normal file
View File

@@ -0,0 +1,184 @@
// 事件类型
export enum EventType {
CLICK = 'click',
REDIRECT = 'redirect',
CONVERSION = 'conversion',
ERROR = 'error'
}
// 转化类型
export enum ConversionType {
VISIT = 'visit',
STAY = 'stay',
INTERACT = 'interact',
SIGNUP = 'signup',
SUBSCRIPTION = 'subscription',
PURCHASE = 'purchase'
}
// 设备类型
export enum DeviceType {
MOBILE = 'mobile',
TABLET = 'tablet',
DESKTOP = 'desktop',
OTHER = 'other'
}
// 标签类型
export interface Tag {
id: string;
name: string;
color?: string;
type?: string;
attributes?: Record<string, any>;
team_id?: string;
}
// API 响应基础接口
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
meta?: {
total?: number;
page?: number;
pageSize?: number;
};
}
// 事件查询参数
export interface EventsQueryParams {
startTime?: string; // ISO 格式时间
endTime?: string; // ISO 格式时间
eventType?: EventType;
linkId?: string;
linkSlug?: string;
userId?: string;
teamId?: string;
teamIds?: string[]; // 团队ID数组支持多选
projectId?: string;
projectIds?: string[]; // 项目ID数组支持多选
tagIds?: string[]; // 标签ID数组支持多选
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
// 事件基础信息
export interface Event {
event_id: string;
event_time: string;
event_type: EventType;
event_attributes: Record<string, any>;
// 链接信息
link_id: string;
link_slug: string;
link_label: string;
link_title: string;
link_original_url: string;
link_attributes: Record<string, any>;
link_created_at: string;
link_expires_at: string | null;
link_tags: string[];
// 用户信息
user_id: string;
user_name: string;
user_email: string;
user_attributes: Record<string, any>;
// 团队信息
team_id: string;
team_name: string;
team_attributes: Record<string, any>;
// 项目信息
project_id: string;
project_name: string;
project_attributes: Record<string, any>;
// 访问者信息
visitor_id: string;
session_id: string;
ip_address: string;
country: string;
city: string;
device_type: DeviceType;
browser: string;
os: string;
user_agent: string;
// 来源信息
referrer: string;
utm_source: string;
utm_medium: string;
utm_campaign: string;
// 交互信息
time_spent_sec: number;
is_bounce: boolean;
is_qr_scan: boolean;
conversion_type: ConversionType;
conversion_value: number;
}
// 事件概览数据
export interface EventsSummary {
totalEvents: number;
uniqueVisitors: number;
totalConversions: number;
averageTimeSpent: number;
deviceTypes: {
mobile: number;
desktop: number;
tablet: number;
other: number;
};
browsers: Array<{
name: string;
count: number;
percentage: number;
}>;
operatingSystems: Array<{
name: string;
count: number;
percentage: number;
}>;
}
// 时间序列数据
export interface TimeSeriesData {
timestamp: string;
events: number;
visitors: number;
conversions: number;
}
// 地理位置数据
export interface GeoData {
location: string;
visits: number;
visitors: number;
percentage: number;
}
// 设备分析数据
export interface DeviceAnalytics {
deviceTypes: Array<{
type: DeviceType;
count: number;
percentage: number;
}>;
browsers: Array<{
name: string;
count: number;
percentage: number;
}>;
operatingSystems: Array<{
name: string;
count: number;
percentage: number;
}>;
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -3,7 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// 设置需要转译的包
transpilePackages: [],
// 配置实验性选项
experimental: {
@@ -14,8 +13,8 @@ const nextConfig: NextConfig = {
// 禁用严格模式,避免开发时重复渲染
reactStrictMode: false,
// 设置输出为独立应用
output: 'standalone',
// 暂时禁用standalone输出模式解决构建问题
// output: 'standalone',
// 忽略ESLint错误不会在构建时中断
eslint: {

454
package-lock.json generated
View File

@@ -9,9 +9,16 @@
"version": "0.1.0",
"dependencies": {
"@clickhouse/client": "^1.11.0",
"@types/chart.js": "^2.9.41",
"@types/recharts": "^1.8.29",
"@types/uuid": "^10.0.0",
"chart.js": "^4.4.8",
"date-fns": "^4.1.0",
"next": "15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^2.15.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -38,6 +45,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@clickhouse/client": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.11.0.tgz",
@@ -654,6 +673,12 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz",
@@ -1136,6 +1161,78 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chart.js": {
"version": "2.9.41",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",
"integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.10.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "^1"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1171,7 +1268,6 @@
"version": "19.0.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1187,6 +1283,22 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/recharts": {
"version": "1.8.29",
"resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.29.tgz",
"integrity": "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw==",
"license": "MIT",
"dependencies": {
"@types/d3-shape": "^1",
"@types/react": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.27.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
@@ -2003,12 +2115,33 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz",
"integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -2080,9 +2213,129 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2144,6 +2397,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -2162,6 +2425,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2228,6 +2497,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2877,6 +3156,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2884,6 +3169,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -3345,6 +3639,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3786,7 +4089,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4151,6 +4453,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4162,7 +4470,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -4228,6 +4535,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4346,7 +4662,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4645,7 +4960,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -4709,7 +5023,75 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
@@ -4735,6 +5117,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -5353,6 +5741,12 @@
"node": ">=6"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
@@ -5584,6 +5978,50 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -24,20 +24,39 @@
},
"dependencies": {
"@clickhouse/client": "^1.11.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/themes": "^3.2.1",
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@types/chart.js": "^2.9.41",
"@types/recharts": "^1.8.29",
"@types/uuid": "^10.0.0",
"chart.js": "^4.4.8",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.486.0",
"next": "15.2.3",
"process": "^0.11.10",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"uuid": "^10.0.0"
"recharts": "^2.15.1",
"tailwind-merge": "^3.1.0",
"uuid": "^10.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@supabase/supabase-js": "^2.49.4",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"css-loader": "^7.1.2",
"dotenv": "^16.4.7",
"eslint": "^9",
"eslint-config-next": "15.2.3",
"style-loader": "^4.0.0",
"tailwindcss": "^4",
"typescript": "^5"
},

2799
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
获取所有表...
数据库 limq 中找到以下表:
- .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
- .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
- .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
- .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
- .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
- .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
- link_daily_stats
- link_events
- link_hourly_patterns
- links
- platform_distribution
- project_daily_stats
- projects
- qr_scans
- qrcode_daily_stats
- qrcodes
- sessions
- team_daily_stats
- team_members
- teams
所有ClickHouse表:
.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
获取表 link_daily_stats 的结构...
表 link_daily_stats 的列:
- date (Date, 无默认值)
- link_id (String, 无默认值)
- total_clicks (UInt64, 无默认值)
- unique_visitors (UInt64, 无默认值)
- unique_sessions (UInt64, 无默认值)
- total_time_spent (UInt64, 无默认值)
- avg_time_spent (Float64, 无默认值)
- bounce_count (UInt64, 无默认值)
- conversion_count (UInt64, 无默认值)
- unique_referrers (UInt64, 无默认值)
- mobile_count (UInt64, 无默认值)
- tablet_count (UInt64, 无默认值)
- desktop_count (UInt64, 无默认值)
- qr_scan_count (UInt64, 无默认值)
- total_conversion_value (Float64, 无默认值)
获取表 link_events 的结构...
表 link_events 的列:
- event_id (UUID, 默认值: generateUUIDv4())
- event_time (DateTime64(3), 默认值: now64())
- date (Date, 默认值: toDate(event_time))
- link_id (String, 无默认值)
- channel_id (String, 无默认值)
- visitor_id (String, 无默认值)
- session_id (String, 无默认值)
- event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
- ip_address (String, 无默认值)
- country (String, 无默认值)
- city (String, 无默认值)
- referrer (String, 无默认值)
- utm_source (String, 无默认值)
- utm_medium (String, 无默认值)
- utm_campaign (String, 无默认值)
- user_agent (String, 无默认值)
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- browser (String, 无默认值)
- os (String, 无默认值)
- time_spent_sec (UInt32, 默认值: 0)
- is_bounce (Bool, 默认值: true)
- is_qr_scan (Bool, 默认值: false)
- qr_code_id (String, 默认值: '')
- conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
- conversion_value (Float64, 默认值: 0)
- custom_data (String, 默认值: '{}')
获取表 link_hourly_patterns 的结构...
表 link_hourly_patterns 的列:
- date (Date, 无默认值)
- hour (UInt8, 无默认值)
- link_id (String, 无默认值)
- visits (UInt64, 无默认值)
- unique_visitors (UInt64, 无默认值)
获取表 links 的结构...
表 links 的列:
- link_id (String, 无默认值)
- original_url (String, 无默认值)
- created_at (DateTime64(3), 无默认值)
- created_by (String, 无默认值)
- title (String, 无默认值)
- description (String, 无默认值)
- tags (Array(String), 无默认值)
- is_active (Bool, 默认值: true)
- expires_at (Nullable(DateTime), 无默认值)
- team_id (String, 默认值: '')
- project_id (String, 默认值: '')
获取表 platform_distribution 的结构...
表 platform_distribution 的列:
- date (Date, 无默认值)
- utm_source (String, 无默认值)
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- visits (UInt64, 无默认值)
- unique_visitors (UInt64, 无默认值)
获取表 project_daily_stats 的结构...
表 project_daily_stats 的列:
- date (Date, 无默认值)
- project_id (String, 无默认值)
- total_clicks (UInt64, 无默认值)
- unique_visitors (UInt64, 无默认值)
- conversion_count (UInt64, 无默认值)
- links_used (UInt64, 无默认值)
- qr_scan_count (UInt64, 无默认值)
获取表 projects 的结构...
表 projects 的列:
- project_id (String, 无默认值)
- team_id (String, 无默认值)
- name (String, 无默认值)
- created_at (DateTime, 无默认值)
- created_by (String, 无默认值)
- description (String, 默认值: '')
- is_archived (Bool, 默认值: false)
- links_count (UInt32, 默认值: 0)
- total_clicks (UInt64, 默认值: 0)
- last_updated (DateTime, 默认值: now())
获取表 qr_scans 的结构...
表 qr_scans 的列:
- scan_id (UUID, 默认值: generateUUIDv4())
- qr_code_id (String, 无默认值)
- link_id (String, 无默认值)
- scan_time (DateTime64(3), 无默认值)
- visitor_id (String, 无默认值)
- location (String, 无默认值)
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
- led_to_conversion (Bool, 默认值: false)
获取表 qrcode_daily_stats 的结构...
表 qrcode_daily_stats 的列:
- date (Date, 无默认值)
- qr_code_id (String, 无默认值)
- total_scans (UInt64, 无默认值)
- unique_scanners (UInt64, 无默认值)
- conversions (UInt64, 无默认值)
- mobile_scans (UInt64, 无默认值)
- tablet_scans (UInt64, 无默认值)
- desktop_scans (UInt64, 无默认值)
- unique_locations (UInt64, 无默认值)
获取表 qrcodes 的结构...
表 qrcodes 的列:
- qr_code_id (String, 无默认值)
- link_id (String, 无默认值)
- team_id (String, 无默认值)
- project_id (String, 默认值: '')
- name (String, 无默认值)
- description (String, 默认值: '')
- created_at (DateTime, 无默认值)
- created_by (String, 无默认值)
- updated_at (DateTime, 默认值: now())
- qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
- image_url (String, 默认值: '')
- design_config (String, 默认值: '{}')
- is_active (Bool, 默认值: true)
- total_scans (UInt64, 默认值: 0)
- unique_scanners (UInt32, 默认值: 0)
获取表 sessions 的结构...
表 sessions 的列:
- session_id (String, 无默认值)
- visitor_id (String, 无默认值)
- link_id (String, 无默认值)
- started_at (DateTime64(3), 无默认值)
- last_activity (DateTime64(3), 无默认值)
- ended_at (Nullable(DateTime64(3)), 无默认值)
- duration_sec (UInt32, 默认值: 0)
- session_pages (UInt8, 默认值: 1)
- is_completed (Bool, 默认值: false)
获取表 team_daily_stats 的结构...
表 team_daily_stats 的列:
- date (Date, 无默认值)
- team_id (String, 无默认值)
- total_clicks (UInt64, 无默认值)
- unique_visitors (UInt64, 无默认值)
- conversion_count (UInt64, 无默认值)
- links_used (UInt64, 无默认值)
- qr_scan_count (UInt64, 无默认值)
获取表 team_members 的结构...
表 team_members 的列:
- team_id (String, 无默认值)
- user_id (String, 无默认值)
- role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
- joined_at (DateTime, 默认值: now())
- invited_by (String, 无默认值)
- is_active (Bool, 默认值: true)
- last_active (DateTime, 默认值: now())
获取表 teams 的结构...
表 teams 的列:
- team_id (String, 无默认值)
- name (String, 无默认值)
- created_at (DateTime, 无默认值)
- created_by (String, 无默认值)
- description (String, 默认值: '')
- avatar_url (String, 默认值: '')
- is_active (Bool, 默认值: true)
- plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
- members_count (UInt32, 默认值: 1)
ClickHouse数据库结构检查完成

View File

@@ -0,0 +1,5 @@
-- 添加domain列到shorturl_analytics.shorturl表
ALTER TABLE
shorturl_analytics.shorturl
ADD
COLUMN IF NOT EXISTS domain Nullable(String) COMMENT '域名';

View File

@@ -0,0 +1,9 @@
-- add_req_full_path.sql
-- Add req_full_path column to the shorturl_analytics.events table
ALTER TABLE
shorturl_analytics.events
ADD
COLUMN IF NOT EXISTS req_full_path String COMMENT 'Full request path including query parameters';
-- Display the updated table structure
DESCRIBE TABLE shorturl_analytics.events;

View File

@@ -0,0 +1,41 @@
-- 添加缺失的UTM参数字段到shorturl_analytics.events表
-- 创建日期: 2024-07-02
-- 用途: 增强UTM参数追踪能力
-- 添加utm_term字段 (用于跟踪付费搜索关键词)
ALTER TABLE
shorturl_analytics.events
ADD
COLUMN utm_term String DEFAULT '' AFTER utm_campaign;
-- 添加utm_content字段 (用于区分相同广告的不同版本或A/B测试)
ALTER TABLE
shorturl_analytics.events
ADD
COLUMN utm_content String DEFAULT '' AFTER utm_term;
-- 验证字段添加成功
DESCRIBE TABLE shorturl_analytics.events;
-- 示例查询: 查看UTM参数分析数据
SELECT
utm_source,
utm_medium,
utm_campaign,
utm_term,
utm_content,
COUNT(*) as clicks
FROM
shorturl_analytics.events
WHERE
event_type = 'click'
AND utm_source != ''
GROUP BY
utm_source,
utm_medium,
utm_campaign,
utm_term,
utm_content
ORDER BY
clicks DESC
LIMIT
10;

View File

@@ -0,0 +1,76 @@
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS shorturl_analytics;
-- 切换到shorturl_analytics数据库
USE shorturl_analytics;
-- 删除已存在的表
DROP TABLE IF EXISTS shorturl_analytics.events;
-- 创建新表
CREATE TABLE IF NOT EXISTS shorturl_analytics.events (
-- 事件基础信息
event_id String,
event_time DateTime64(3),
-- 精确到毫秒的时间戳
event_type String,
-- click, redirect, conversion, error
event_attributes String DEFAULT '{}',
-- 链接基本信息
link_id String,
link_slug String,
-- 新增slug
link_label String,
-- 新增label
link_title String,
link_original_url String,
link_attributes String DEFAULT '{}',
link_created_at DateTime64(3),
-- 精确到毫秒的时间戳
link_expires_at Nullable(DateTime64(3)),
-- 精确到毫秒的时间戳
link_tags String DEFAULT '[]',
-- Array of {id, name, attributes}
-- 用户信息
user_id String,
user_name String,
user_email String,
user_attributes String DEFAULT '{}',
-- 团队信息
team_id String,
team_name String,
team_attributes String DEFAULT '{}',
-- 项目信息
project_id String,
project_name String,
project_attributes String DEFAULT '{}',
-- QR码信息
qr_code_id String,
qr_code_name String,
qr_code_attributes String DEFAULT '{}',
-- 访问者信息
visitor_id String,
session_id String,
ip_address String,
country String,
city String,
device_type String,
-- 改为String类型
browser String,
os String,
user_agent String,
-- 来源信息
referrer String,
utm_source String,
utm_medium String,
utm_campaign String,
-- 交互信息
time_spent_sec UInt32 DEFAULT 0,
is_bounce Boolean DEFAULT true,
is_qr_scan Boolean DEFAULT false,
conversion_type String,
-- 改为String类型
conversion_value Float64 DEFAULT 0
) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time) -- 直接使用DateTime64进行分区
ORDER BY
(event_time, link_id, event_id) SETTINGS index_granularity = 8192;

View File

@@ -0,0 +1,46 @@
-- 使用shorturl_analytics数据库
USE shorturl_analytics;
-- 删除已存在的shorturl表
DROP TABLE IF EXISTS shorturl_analytics.shorturl;
-- 创建shorturl表
CREATE TABLE IF NOT EXISTS shorturl_analytics.shorturl (
-- 短链接基本信息来源于resources表
id String COMMENT '资源ID (resources.id)',
external_id String COMMENT '外部ID (resources.external_id)',
type String COMMENT '类型值为shorturl',
slug String COMMENT '短链接slug (存储在attributes中)',
original_url String COMMENT '原始URL (存储在attributes中)',
title String COMMENT '标题 (存储在attributes中)',
description String COMMENT '描述 (存储在attributes中)',
attributes String DEFAULT '{}' COMMENT '资源属性JSON',
schema_version Int32 COMMENT 'Schema版本',
-- 创建者信息
creator_id String COMMENT '创建者ID (resources.creator_id)',
creator_email String COMMENT '创建者邮箱 (来自users表)',
creator_name String COMMENT '创建者名称 (来自users表)',
-- 时间信息
created_at DateTime64(3) COMMENT '创建时间 (resources.created_at)',
updated_at DateTime64(3) COMMENT '更新时间 (resources.updated_at)',
deleted_at Nullable(DateTime64(3)) COMMENT '删除时间 (resources.deleted_at)',
-- 项目关联 (project_resources表)
projects String DEFAULT '[]' COMMENT '项目关联信息数组。结构: [{project_id: String, project_name: String, project_description: String, assigned_at: DateTime64}]',
-- 团队关联 (通过项目关联到团队)
teams String DEFAULT '[]' COMMENT '团队关联信息数组。结构: [{team_id: String, team_name: String, team_description: String, via_project_id: String}]',
-- 标签关联 (resource_tags表)
tags String DEFAULT '[]' COMMENT '标签关联信息数组。结构: [{tag_id: String, tag_name: String, tag_type: String, created_at: DateTime64}]',
-- QR码关联 (qr_code表)
qr_codes String DEFAULT '[]' COMMENT 'QR码信息数组。结构: [{qr_id: String, scan_count: Int32, url: String, template_name: String, created_at: DateTime64}]',
-- 渠道关联 (channel表)
channels String DEFAULT '[]' COMMENT '渠道信息数组。结构: [{channel_id: String, channel_name: String, channel_path: String, is_user_created: Boolean}]',
-- 收藏关联 (favorite表)
favorites String DEFAULT '[]' COMMENT '收藏信息数组。结构: [{favorite_id: String, user_id: String, user_name: String, created_at: DateTime64}]',
-- 自定义过期时间 (存储在attributes中)
expires_at Nullable(DateTime64(3)) COMMENT '过期时间',
-- 统计信息 (分析时聚合计算)
click_count UInt32 DEFAULT 0 COMMENT '点击次数',
unique_visitors UInt32 DEFAULT 0 COMMENT '唯一访问者数'
) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at)
ORDER BY
(id, created_at) SETTINGS index_granularity = 8192 COMMENT '用于存储所有shorturl类型资源的统一表集成了相关联的项目、团队、标签、QR码、渠道和收藏信息';

View File

@@ -1,146 +0,0 @@
-- 添加team、project和qrcode表到limq数据库
USE limq;
-- 团队表
CREATE TABLE IF NOT EXISTS limq.teams (
team_id String,
name String,
created_at DateTime,
created_by String,
description String DEFAULT '',
avatar_url String DEFAULT '',
is_active Boolean DEFAULT true,
plan_type Enum8(
'free' = 1,
'pro' = 2,
'enterprise' = 3
),
members_count UInt32 DEFAULT 1,
PRIMARY KEY (team_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
team_id SETTINGS index_granularity = 8192;
-- 项目表
CREATE TABLE IF NOT EXISTS limq.projects (
project_id String,
team_id String,
name String,
created_at DateTime,
created_by String,
description String DEFAULT '',
is_archived Boolean DEFAULT false,
links_count UInt32 DEFAULT 0,
total_clicks UInt64 DEFAULT 0,
last_updated DateTime DEFAULT now(),
PRIMARY KEY (project_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(project_id, team_id) SETTINGS index_granularity = 8192;
-- QR码表 (扩展现有的qr_scans表)
CREATE TABLE IF NOT EXISTS limq.qrcodes (
qr_code_id String,
link_id String,
team_id String,
project_id String DEFAULT '',
name String,
description String DEFAULT '',
created_at DateTime,
created_by String,
updated_at DateTime DEFAULT now(),
qr_type Enum8(
'standard' = 1,
'custom' = 2,
'dynamic' = 3
) DEFAULT 'standard',
image_url String DEFAULT '',
design_config String DEFAULT '{}',
is_active Boolean DEFAULT true,
total_scans UInt64 DEFAULT 0,
unique_scanners UInt32 DEFAULT 0,
PRIMARY KEY (qr_code_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(qr_code_id, link_id) SETTINGS index_granularity = 8192;
-- 团队成员表
CREATE TABLE IF NOT EXISTS limq.team_members (
team_id String,
user_id String,
role Enum8(
'owner' = 1,
'admin' = 2,
'editor' = 3,
'viewer' = 4
),
joined_at DateTime DEFAULT now(),
invited_by String,
is_active Boolean DEFAULT true,
last_active DateTime DEFAULT now(),
PRIMARY KEY (team_id, user_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(team_id, user_id) SETTINGS index_granularity = 8192;
-- 团队每日统计视图
CREATE MATERIALIZED VIEW limq.team_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, team_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.team_id AS team_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.team_id != ''
GROUP BY
date,
l.team_id;
-- 项目每日统计视图
CREATE MATERIALIZED VIEW limq.project_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, project_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.project_id AS project_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.project_id != ''
GROUP BY
date,
l.project_id;
-- QR码每日统计视图
CREATE MATERIALIZED VIEW limq.qrcode_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, qr_code_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(scan_time) AS date,
qr_code_id,
count() AS total_scans,
uniqExact(visitor_id) AS unique_scanners,
countIf(led_to_conversion) AS conversions,
countIf(device_type = 'mobile') AS mobile_scans,
countIf(device_type = 'tablet') AS tablet_scans,
countIf(device_type = 'desktop') AS desktop_scans,
uniqExact(location) AS unique_locations
FROM
limq.qr_scans
GROUP BY
date,
qr_code_id;

View File

@@ -1,29 +0,0 @@
#!/bin/bash
# 脚本名称: load-clickhouse-testdata.sh
# 用途: 将测试数据加载到ClickHouse数据库中
# 设置脚本目录路径
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 设置SQL文件路径
SQL_FILE="$SCRIPT_DIR/sql/clickhouse/seed-clickhouse-analytics.sql"
# 检查SQL文件是否存在
if [ ! -f "$SQL_FILE" ]; then
echo "错误: SQL文件 '$SQL_FILE' 不存在"
exit 1
fi
# 执行CH查询脚本
echo "开始加载测试数据到ClickHouse数据库..."
bash "$SCRIPT_DIR/sql/clickhouse/ch-query.sh" -f "$SQL_FILE"
# 检查执行结果
if [ $? -eq 0 ]; then
echo "测试数据已成功加载到ClickHouse数据库"
else
echo "错误: 加载测试数据失败"
exit 1
fi
exit 0

View File

@@ -1,997 +0,0 @@
-- 移动端点击访问事件
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 10:25:30',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-123',
's-456',
'click',
'103.45.67.89',
'China',
'Shanghai',
'https://www.google.com',
'google',
'organic',
'none',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
45,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 11:32:21',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-124',
's-457',
'click',
'43.78.123.45',
'Japan',
'Tokyo',
'https://twitter.com',
'twitter',
'social',
'spring_promo',
'Mozilla/5.0 (Android 10)',
'mobile',
'Chrome',
'Android',
15,
true,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 14:15:45',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-125',
's-458',
'click',
'72.34.67.81',
'US',
'New York',
'https://www.facebook.com',
'facebook',
'social',
'crypto_ad',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
120,
false,
false,
'interact',
0
);
-- 桌面设备点击事件
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 08:45:12',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-126',
's-459',
'click',
'89.67.43.21',
'Germany',
'Berlin',
'https://www.reddit.com',
'reddit',
'referral',
'none',
'Mozilla/5.0 (Windows NT 10.0)',
'desktop',
'Chrome',
'Windows',
300,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 16:20:33',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-127',
's-460',
'click',
'178.65.43.12',
'UK',
'London',
'https://www.linkedin.com',
'linkedin',
'social',
'biz_campaign',
'Mozilla/5.0 (Macintosh)',
'desktop',
'Safari',
'MacOS',
250,
false,
false,
'stay',
0
);
-- 平板设备点击事件
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 13:10:55',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-128',
's-461',
'click',
'156.78.34.12',
'Canada',
'Toronto',
'https://www.youtube.com',
'youtube',
'video',
'tutorial',
'Mozilla/5.0 (iPad)',
'tablet',
'Safari',
'iOS',
180,
false,
false,
'visit',
0
);
-- QR扫描访问事件
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 09:30:22',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_qr',
'v-129',
's-462',
'click',
'101.56.78.90',
'China',
'Beijing',
'direct',
'qr',
'print',
'offline_event',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
75,
false,
true,
'visit',
0
);
-- 转化事件
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 10:27:45',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-123',
's-456',
'conversion',
'103.45.67.89',
'China',
'Shanghai',
'https://www.google.com',
'google',
'organic',
'none',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
120,
false,
false,
'signup',
50
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-15 08:52:18',
'2025-03-15',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-126',
's-459',
'conversion',
'89.67.43.21',
'Germany',
'Berlin',
'https://www.reddit.com',
'reddit',
'referral',
'none',
'Mozilla/5.0 (Windows NT 10.0)',
'desktop',
'Chrome',
'Windows',
450,
false,
false,
'purchase',
150.75
);
-- 第二天的数据 (3/16)
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-16 11:15:30',
'2025-03-16',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-130',
's-463',
'click',
'178.91.45.67',
'France',
'Paris',
'https://www.google.com',
'google',
'organic',
'none',
'Mozilla/5.0 (Android 11)',
'mobile',
'Chrome',
'Android',
60,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-16 14:22:45',
'2025-03-16',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-131',
's-464',
'click',
'89.123.45.78',
'Spain',
'Madrid',
'https://www.instagram.com',
'instagram',
'social',
'influencer',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
90,
false,
false,
'interact',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-16 16:40:12',
'2025-03-16',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-131',
's-464',
'conversion',
'89.123.45.78',
'Spain',
'Madrid',
'https://www.instagram.com',
'instagram',
'social',
'influencer',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
200,
false,
false,
'subscription',
75.50
);
-- 第三天数据 (3/17)
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-17 09:10:22',
'2025-03-17',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-132',
's-465',
'click',
'45.67.89.123',
'US',
'Los Angeles',
'https://www.google.com',
'google',
'cpc',
'spring_sale',
'Mozilla/5.0 (Windows NT 10.0)',
'desktop',
'Edge',
'Windows',
150,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-17 12:30:45',
'2025-03-17',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-133',
's-466',
'click',
'67.89.123.45',
'Brazil',
'Sao Paulo',
'https://www.yahoo.com',
'yahoo',
'organic',
'none',
'Mozilla/5.0 (iPad)',
'tablet',
'Safari',
'iOS',
120,
false,
false,
'stay',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-17 15:45:33',
'2025-03-17',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-132',
's-465',
'conversion',
'45.67.89.123',
'US',
'Los Angeles',
'https://www.google.com',
'google',
'cpc',
'spring_sale',
'Mozilla/5.0 (Windows NT 10.0)',
'desktop',
'Edge',
'Windows',
300,
false,
false,
'purchase',
225.50
);
-- 添加一周前的数据 (对比期)
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-08 10:25:30',
'2025-03-08',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-140',
's-470',
'click',
'103.45.67.89',
'China',
'Shanghai',
'https://www.google.com',
'google',
'organic',
'none',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
30,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-08 11:32:21',
'2025-03-08',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-141',
's-471',
'click',
'89.67.43.21',
'Germany',
'Berlin',
'https://www.reddit.com',
'reddit',
'referral',
'none',
'Mozilla/5.0 (Windows NT 10.0)',
'desktop',
'Chrome',
'Windows',
200,
false,
false,
'visit',
0
);
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
)
VALUES
(
generateUUIDv4(),
'2025-03-08 13:10:55',
'2025-03-08',
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
'ch_main',
'v-140',
's-470',
'conversion',
'103.45.67.89',
'China',
'Shanghai',
'https://www.google.com',
'google',
'organic',
'none',
'Mozilla/5.0 (iPhone)',
'mobile',
'Safari',
'iOS',
100,
false,
false,
'purchase',
100.00
);

View File

@@ -1,122 +0,0 @@
-- 修改设备类型字段从枚举类型更改为字符串类型
-- 先删除依赖于link_events表的物化视图
DROP TABLE IF EXISTS limq.platform_distribution;
DROP TABLE IF EXISTS limq.link_hourly_patterns;
DROP TABLE IF EXISTS limq.link_daily_stats;
DROP TABLE IF EXISTS limq.team_daily_stats;
DROP TABLE IF EXISTS limq.project_daily_stats;
-- 修改link_events表的device_type字段
ALTER TABLE
limq.link_events
MODIFY
COLUMN device_type String;
-- 重新创建物化视图
-- 每日链接汇总视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, link_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
link_id,
count() AS total_clicks,
uniqExact(visitor_id) AS unique_visitors,
uniqExact(session_id) AS unique_sessions,
sum(time_spent_sec) AS total_time_spent,
avg(time_spent_sec) AS avg_time_spent,
countIf(is_bounce) AS bounce_count,
countIf(event_type = 'conversion') AS conversion_count,
uniqExact(referrer) AS unique_referrers,
countIf(device_type = 'mobile') AS mobile_count,
countIf(device_type = 'tablet') AS tablet_count,
countIf(device_type = 'desktop') AS desktop_count,
countIf(is_qr_scan) AS qr_scan_count,
sum(conversion_value) AS total_conversion_value
FROM
limq.link_events
GROUP BY
date,
link_id;
-- 每小时访问模式视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_hourly_patterns ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, hour, link_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
toHour(event_time) AS hour,
link_id,
count() AS visits,
uniqExact(visitor_id) AS unique_visitors
FROM
limq.link_events
GROUP BY
date,
hour,
link_id;
-- 平台分布视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.platform_distribution ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, utm_source, device_type) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
utm_source,
device_type,
count() AS visits,
uniqExact(visitor_id) AS unique_visitors
FROM
limq.link_events
WHERE
utm_source != ''
GROUP BY
date,
utm_source,
device_type;
-- 团队每日统计视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.team_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, team_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.team_id AS team_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.team_id != ''
GROUP BY
date,
l.team_id;
-- 项目每日统计视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.project_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, project_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.project_id AS project_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.project_id != ''
GROUP BY
date,
l.project_id;

View File

@@ -1,379 +0,0 @@
-- 删除所有物化视图(需要先删除视图,因为它们依赖于表)
DROP TABLE IF EXISTS limq.platform_distribution;
DROP TABLE IF EXISTS limq.link_hourly_patterns;
DROP TABLE IF EXISTS limq.link_daily_stats;
DROP TABLE IF EXISTS limq.team_daily_stats;
DROP TABLE IF EXISTS limq.project_daily_stats;
DROP TABLE IF EXISTS limq.qrcode_daily_stats;
-- 删除所有表
DROP TABLE IF EXISTS limq.qr_scans;
DROP TABLE IF EXISTS limq.sessions;
DROP TABLE IF EXISTS limq.link_events;
DROP TABLE IF EXISTS limq.links;
DROP TABLE IF EXISTS limq.teams;
DROP TABLE IF EXISTS limq.projects;
DROP TABLE IF EXISTS limq.qrcodes;
DROP TABLE IF EXISTS limq.team_members;
DROP TABLE IF EXISTS limq.users;
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS limq;
-- 切换到limq数据库
USE limq;
-- 创建短链接访问事件表
CREATE TABLE IF NOT EXISTS limq.link_events (
event_id UUID DEFAULT generateUUIDv4(),
event_time DateTime64(3) DEFAULT now64(),
date Date DEFAULT toDate(event_time),
link_id String,
channel_id String,
visitor_id String,
session_id String,
event_type Enum8(
'click' = 1,
'redirect' = 2,
'conversion' = 3,
'error' = 4
),
-- 访问者信息
ip_address String,
country String,
city String,
-- 来源信息
referrer String,
utm_source String,
utm_medium String,
utm_campaign String,
-- 设备信息
user_agent String,
device_type Enum8(
'mobile' = 1,
'tablet' = 2,
'desktop' = 3,
'other' = 4
),
browser String,
os String,
-- 交互信息
time_spent_sec UInt32 DEFAULT 0,
is_bounce Boolean DEFAULT true,
-- QR码相关
is_qr_scan Boolean DEFAULT false,
qr_code_id String DEFAULT '',
-- 转化数据
conversion_type Enum8(
'visit' = 1,
'stay' = 2,
'interact' = 3,
'signup' = 4,
'subscription' = 5,
'purchase' = 6
) DEFAULT 'visit',
conversion_value Float64 DEFAULT 0,
-- 其他属性
custom_data String DEFAULT '{}'
) ENGINE = MergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, link_id, event_time) SETTINGS index_granularity = 8192;
-- 短链接维度表
CREATE TABLE IF NOT EXISTS limq.links (
link_id String,
original_url String,
created_at DateTime64(3),
created_by String,
title String,
description String,
tags Array(String),
is_active Boolean DEFAULT true,
expires_at Nullable(DateTime64(3)),
team_id String DEFAULT '',
project_id String DEFAULT '',
PRIMARY KEY (link_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
link_id SETTINGS index_granularity = 8192;
-- 会话跟踪表
CREATE TABLE IF NOT EXISTS limq.sessions (
session_id String,
visitor_id String,
link_id String,
started_at DateTime64(3),
last_activity DateTime64(3),
ended_at Nullable(DateTime64(3)),
duration_sec UInt32 DEFAULT 0,
session_pages UInt8 DEFAULT 1,
is_completed Boolean DEFAULT false,
PRIMARY KEY (session_id)
) ENGINE = ReplacingMergeTree(last_activity)
ORDER BY
(session_id, link_id, visitor_id) SETTINGS index_granularity = 8192;
-- QR码统计表
CREATE TABLE IF NOT EXISTS limq.qr_scans (
scan_id UUID DEFAULT generateUUIDv4(),
qr_code_id String,
link_id String,
scan_time DateTime64(3),
visitor_id String,
location String,
device_type Enum8(
'mobile' = 1,
'tablet' = 2,
'desktop' = 3,
'other' = 4
),
led_to_conversion Boolean DEFAULT false,
PRIMARY KEY (scan_id)
) ENGINE = MergeTree() PARTITION BY toYYYYMM(scan_time)
ORDER BY
scan_id SETTINGS index_granularity = 8192;
-- 团队表
CREATE TABLE IF NOT EXISTS limq.teams (
team_id String,
name String,
created_at DateTime,
created_by String,
description String DEFAULT '',
avatar_url String DEFAULT '',
is_active Boolean DEFAULT true,
plan_type Enum8(
'free' = 1,
'pro' = 2,
'enterprise' = 3
),
members_count UInt32 DEFAULT 1,
PRIMARY KEY (team_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
team_id SETTINGS index_granularity = 8192;
-- 项目表
CREATE TABLE IF NOT EXISTS limq.projects (
project_id String,
team_id String,
name String,
created_at DateTime,
created_by String,
description String DEFAULT '',
is_archived Boolean DEFAULT false,
links_count UInt32 DEFAULT 0,
total_clicks UInt64 DEFAULT 0,
last_updated DateTime DEFAULT now(),
PRIMARY KEY (project_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(project_id, team_id) SETTINGS index_granularity = 8192;
-- QR码表
CREATE TABLE IF NOT EXISTS limq.qrcodes (
qr_code_id String,
link_id String,
team_id String,
project_id String DEFAULT '',
name String,
description String DEFAULT '',
created_at DateTime,
created_by String,
updated_at DateTime DEFAULT now(),
qr_type Enum8(
'standard' = 1,
'custom' = 2,
'dynamic' = 3
) DEFAULT 'standard',
image_url String DEFAULT '',
design_config String DEFAULT '{}',
is_active Boolean DEFAULT true,
total_scans UInt64 DEFAULT 0,
unique_scanners UInt32 DEFAULT 0,
PRIMARY KEY (qr_code_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(qr_code_id, link_id) SETTINGS index_granularity = 8192;
-- 团队成员表
CREATE TABLE IF NOT EXISTS limq.team_members (
team_id String,
user_id String,
role Enum8(
'owner' = 1,
'admin' = 2,
'editor' = 3,
'viewer' = 4
),
joined_at DateTime DEFAULT now(),
invited_by String,
is_active Boolean DEFAULT true,
last_active DateTime DEFAULT now(),
PRIMARY KEY (team_id, user_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
(team_id, user_id) SETTINGS index_granularity = 8192;
-- 用户表
CREATE TABLE IF NOT EXISTS limq.users (
user_id String,
username String,
email String,
full_name String,
avatar_url String DEFAULT '',
created_at DateTime,
last_login DateTime DEFAULT now(),
is_active Boolean DEFAULT true,
is_verified Boolean DEFAULT false,
auth_provider Enum8(
'email' = 1,
'google' = 2,
'github' = 3,
'microsoft' = 4
) DEFAULT 'email',
roles Array(String) DEFAULT [ 'user' ],
preferences String DEFAULT '{}',
teams_count UInt32 DEFAULT 0,
links_created UInt32 DEFAULT 0,
PRIMARY KEY (user_id)
) ENGINE = ReplacingMergeTree()
ORDER BY
user_id SETTINGS index_granularity = 8192;
-- 每日链接汇总视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, link_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
link_id,
count() AS total_clicks,
uniqExact(visitor_id) AS unique_visitors,
uniqExact(session_id) AS unique_sessions,
sum(time_spent_sec) AS total_time_spent,
avg(time_spent_sec) AS avg_time_spent,
countIf(is_bounce) AS bounce_count,
countIf(event_type = 'conversion') AS conversion_count,
uniqExact(referrer) AS unique_referrers,
countIf(device_type = 'mobile') AS mobile_count,
countIf(device_type = 'tablet') AS tablet_count,
countIf(device_type = 'desktop') AS desktop_count,
countIf(is_qr_scan) AS qr_scan_count,
sum(conversion_value) AS total_conversion_value
FROM
limq.link_events
GROUP BY
date,
link_id;
-- 每小时访问模式视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_hourly_patterns ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, hour, link_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
toHour(event_time) AS hour,
link_id,
count() AS visits,
uniqExact(visitor_id) AS unique_visitors
FROM
limq.link_events
GROUP BY
date,
hour,
link_id;
-- 平台分布视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.platform_distribution ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, utm_source, device_type) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
utm_source,
device_type,
count() AS visits,
uniqExact(visitor_id) AS unique_visitors
FROM
limq.link_events
WHERE
utm_source != ''
GROUP BY
date,
utm_source,
device_type;
-- 团队每日统计视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.team_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, team_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.team_id AS team_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.team_id != ''
GROUP BY
date,
l.team_id;
-- 项目每日统计视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.project_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, project_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(event_time) AS date,
l.project_id AS project_id,
count() AS total_clicks,
uniqExact(e.visitor_id) AS unique_visitors,
countIf(e.event_type = 'conversion') AS conversion_count,
uniqExact(e.link_id) AS links_used,
countIf(e.is_qr_scan) AS qr_scan_count
FROM
limq.link_events e
JOIN limq.links l ON e.link_id = l.link_id
WHERE
l.project_id != ''
GROUP BY
date,
l.project_id;
-- QR码每日统计视图
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.qrcode_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
ORDER BY
(date, qr_code_id) SETTINGS index_granularity = 8192 AS
SELECT
toDate(scan_time) AS date,
qr_code_id,
count() AS total_scans,
uniqExact(visitor_id) AS unique_scanners,
countIf(led_to_conversion) AS conversions,
countIf(device_type = 'mobile') AS mobile_scans,
countIf(device_type = 'tablet') AS tablet_scans,
countIf(device_type = 'desktop') AS desktop_scans,
uniqExact(location) AS unique_locations
FROM
limq.qr_scans
GROUP BY
date,
qr_code_id;

View File

@@ -1,828 +0,0 @@
-- 清空现有数据(可选)
TRUNCATE TABLE IF EXISTS limq.link_events;
TRUNCATE TABLE IF EXISTS limq.link_daily_stats;
TRUNCATE TABLE IF EXISTS limq.link_hourly_patterns;
TRUNCATE TABLE IF EXISTS limq.links;
-- 使用固定的UUID值插入链接
INSERT INTO
limq.links (
link_id,
original_url,
created_at,
created_by,
title,
description,
tags,
is_active
)
VALUES
(
'11111111-1111-1111-1111-111111111111',
'https://example.com/page1',
now(),
'user-1',
'产品页面',
'我们的主要产品页面',
[ '产品',
'营销' ],
true
);
INSERT INTO
limq.links (
link_id,
original_url,
created_at,
created_by,
title,
description,
tags,
is_active
)
VALUES
(
'22222222-2222-2222-2222-222222222222',
'https://example.com/promo',
now(),
'user-1',
'促销活动',
'夏季特别促销活动',
[ '促销',
'活动' ],
true
);
INSERT INTO
limq.links (
link_id,
original_url,
created_at,
created_by,
title,
description,
tags,
is_active
)
VALUES
(
'33333333-3333-3333-3333-333333333333',
'https://example.com/blog',
now(),
'user-2',
'公司博客',
'公司新闻和更新',
[ '博客',
'内容' ],
true
);
INSERT INTO
limq.links (
link_id,
original_url,
created_at,
created_by,
title,
description,
tags,
is_active
)
VALUES
(
'44444444-4444-4444-4444-444444444444',
'https://example.com/signup',
now(),
'user-2',
'注册页面',
'新用户注册页面',
[ '转化',
'注册' ],
true
);
-- 为第一个链接创建500条记录
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
qr_code_id,
conversion_type,
conversion_value,
custom_data
)
SELECT
generateUUIDv4() AS event_id,
subtractDays(now(), rand() % 30) AS event_time,
toDate(event_time) AS date,
'11111111-1111-1111-1111-111111111111' AS link_id,
'channel-1' AS channel_id,
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
concat('session-', toString(number % 50 + 1)) AS session_id,
multiIf(
rand() % 100 < 70,
'click',
rand() % 100 < 90,
'redirect',
rand() % 100 < 98,
'conversion',
'error'
) AS event_type,
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
multiIf(
rand() % 100 < 60,
'China',
rand() % 100 < 85,
'US',
rand() % 100 < 95,
'Japan',
'Other'
) AS country,
multiIf(
rand() % 100 < 60,
'Beijing',
rand() % 100 < 85,
'New York',
rand() % 100 < 95,
'Tokyo',
'Other'
) AS city,
multiIf(
rand() % 100 < 30,
'https://google.com',
rand() % 100 < 50,
'https://facebook.com',
rand() % 100 < 65,
'https://twitter.com',
rand() % 100 < 75,
'https://instagram.com',
rand() % 100 < 85,
'https://linkedin.com',
rand() % 100 < 90,
'https://bing.com',
rand() % 100 < 95,
'https://baidu.com',
'direct'
) AS referrer,
multiIf(
rand() % 100 < 40,
'google',
rand() % 100 < 70,
'facebook',
rand() % 100 < 90,
'email',
'direct'
) AS utm_source,
multiIf(
rand() % 100 < 40,
'cpc',
rand() % 100 < 70,
'social',
rand() % 100 < 90,
'email',
'direct'
) AS utm_medium,
multiIf(
rand() % 100 < 40,
'summer_sale',
rand() % 100 < 70,
'product_launch',
rand() % 100 < 90,
'newsletter',
'brand'
) AS utm_campaign,
'Mozilla/5.0' AS user_agent,
multiIf(
rand() % 100 < 60,
'mobile',
rand() % 100 < 85,
'desktop',
rand() % 100 < 95,
'tablet',
'other'
) AS device_type,
multiIf(
rand() % 100 < 50,
'Chrome',
rand() % 100 < 80,
'Safari',
rand() % 100 < 95,
'Firefox',
'Edge'
) AS browser,
multiIf(
rand() % 100 < 50,
'iOS',
rand() % 100 < 90,
'Android',
'Windows'
) AS os,
rand() % 300 AS time_spent_sec,
rand() % 100 < 25 AS is_bounce,
rand() % 100 < 20 AS is_qr_scan,
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
multiIf(
rand() % 100 < 50,
'visit',
rand() % 100 < 70,
'stay',
rand() % 100 < 85,
'interact',
rand() % 100 < 93,
'signup',
rand() % 100 < 97,
'subscription',
'purchase'
) AS conversion_type,
rand() % 100 * 1.5 AS conversion_value,
'{}' AS custom_data
FROM
numbers(500);
-- 为第二个链接创建300条记录
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
qr_code_id,
conversion_type,
conversion_value,
custom_data
)
SELECT
generateUUIDv4() AS event_id,
subtractDays(now(), rand() % 30) AS event_time,
toDate(event_time) AS date,
'22222222-2222-2222-2222-222222222222' AS link_id,
'channel-1' AS channel_id,
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
concat('session-', toString(number % 40 + 1)) AS session_id,
multiIf(
rand() % 100 < 70,
'click',
rand() % 100 < 90,
'redirect',
rand() % 100 < 98,
'conversion',
'error'
) AS event_type,
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
multiIf(
rand() % 100 < 60,
'China',
rand() % 100 < 85,
'US',
rand() % 100 < 95,
'Japan',
'Other'
) AS country,
multiIf(
rand() % 100 < 60,
'Beijing',
rand() % 100 < 85,
'New York',
rand() % 100 < 95,
'Tokyo',
'Other'
) AS city,
multiIf(
rand() % 100 < 30,
'https://google.com',
rand() % 100 < 50,
'https://facebook.com',
rand() % 100 < 65,
'https://twitter.com',
rand() % 100 < 75,
'https://instagram.com',
rand() % 100 < 85,
'https://linkedin.com',
rand() % 100 < 90,
'https://bing.com',
rand() % 100 < 95,
'https://baidu.com',
'direct'
) AS referrer,
multiIf(
rand() % 100 < 40,
'google',
rand() % 100 < 70,
'facebook',
rand() % 100 < 90,
'email',
'direct'
) AS utm_source,
multiIf(
rand() % 100 < 40,
'cpc',
rand() % 100 < 70,
'social',
rand() % 100 < 90,
'email',
'direct'
) AS utm_medium,
multiIf(
rand() % 100 < 40,
'summer_sale',
rand() % 100 < 70,
'product_launch',
rand() % 100 < 90,
'newsletter',
'brand'
) AS utm_campaign,
'Mozilla/5.0' AS user_agent,
multiIf(
rand() % 100 < 60,
'mobile',
rand() % 100 < 85,
'desktop',
rand() % 100 < 95,
'tablet',
'other'
) AS device_type,
multiIf(
rand() % 100 < 50,
'Chrome',
rand() % 100 < 80,
'Safari',
rand() % 100 < 95,
'Firefox',
'Edge'
) AS browser,
multiIf(
rand() % 100 < 50,
'iOS',
rand() % 100 < 90,
'Android',
'Windows'
) AS os,
rand() % 300 AS time_spent_sec,
rand() % 100 < 25 AS is_bounce,
rand() % 100 < 15 AS is_qr_scan,
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
multiIf(
rand() % 100 < 50,
'visit',
rand() % 100 < 70,
'stay',
rand() % 100 < 85,
'interact',
rand() % 100 < 93,
'signup',
rand() % 100 < 97,
'subscription',
'purchase'
) AS conversion_type,
rand() % 100 * 2.5 AS conversion_value,
'{}' AS custom_data
FROM
numbers(300);
-- 为第三个链接创建200条记录
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
qr_code_id,
conversion_type,
conversion_value,
custom_data
)
SELECT
generateUUIDv4() AS event_id,
subtractDays(now(), rand() % 30) AS event_time,
toDate(event_time) AS date,
'33333333-3333-3333-3333-333333333333' AS link_id,
'channel-2' AS channel_id,
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
concat('session-', toString(number % 30 + 1)) AS session_id,
multiIf(
rand() % 100 < 70,
'click',
rand() % 100 < 90,
'redirect',
rand() % 100 < 98,
'conversion',
'error'
) AS event_type,
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
multiIf(
rand() % 100 < 60,
'China',
rand() % 100 < 85,
'US',
rand() % 100 < 95,
'Japan',
'Other'
) AS country,
multiIf(
rand() % 100 < 60,
'Beijing',
rand() % 100 < 85,
'New York',
rand() % 100 < 95,
'Tokyo',
'Other'
) AS city,
multiIf(
rand() % 100 < 30,
'https://google.com',
rand() % 100 < 50,
'https://facebook.com',
rand() % 100 < 65,
'https://twitter.com',
rand() % 100 < 75,
'https://instagram.com',
rand() % 100 < 85,
'https://linkedin.com',
rand() % 100 < 90,
'https://bing.com',
rand() % 100 < 95,
'https://baidu.com',
'direct'
) AS referrer,
multiIf(
rand() % 100 < 40,
'google',
rand() % 100 < 70,
'facebook',
rand() % 100 < 90,
'email',
'direct'
) AS utm_source,
multiIf(
rand() % 100 < 40,
'cpc',
rand() % 100 < 70,
'social',
rand() % 100 < 90,
'email',
'direct'
) AS utm_medium,
multiIf(
rand() % 100 < 40,
'summer_sale',
rand() % 100 < 70,
'product_launch',
rand() % 100 < 90,
'newsletter',
'brand'
) AS utm_campaign,
'Mozilla/5.0' AS user_agent,
multiIf(
rand() % 100 < 60,
'mobile',
rand() % 100 < 85,
'desktop',
rand() % 100 < 95,
'tablet',
'other'
) AS device_type,
multiIf(
rand() % 100 < 50,
'Chrome',
rand() % 100 < 80,
'Safari',
rand() % 100 < 95,
'Firefox',
'Edge'
) AS browser,
multiIf(
rand() % 100 < 50,
'iOS',
rand() % 100 < 90,
'Android',
'Windows'
) AS os,
rand() % 600 AS time_spent_sec,
rand() % 100 < 15 AS is_bounce,
rand() % 100 < 10 AS is_qr_scan,
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
multiIf(
rand() % 100 < 50,
'visit',
rand() % 100 < 70,
'stay',
rand() % 100 < 85,
'interact',
rand() % 100 < 93,
'signup',
rand() % 100 < 97,
'subscription',
'purchase'
) AS conversion_type,
rand() % 100 * 1.2 AS conversion_value,
'{}' AS custom_data
FROM
numbers(200);
-- 为第四个链接创建400条记录
INSERT INTO
limq.link_events (
event_id,
event_time,
date,
link_id,
channel_id,
visitor_id,
session_id,
event_type,
ip_address,
country,
city,
referrer,
utm_source,
utm_medium,
utm_campaign,
user_agent,
device_type,
browser,
os,
time_spent_sec,
is_bounce,
is_qr_scan,
qr_code_id,
conversion_type,
conversion_value,
custom_data
)
SELECT
generateUUIDv4() AS event_id,
subtractDays(now(), rand() % 30) AS event_time,
toDate(event_time) AS date,
'44444444-4444-4444-4444-444444444444' AS link_id,
'channel-2' AS channel_id,
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
concat('session-', toString(number % 60 + 1)) AS session_id,
multiIf(
rand() % 100 < 70,
'click',
rand() % 100 < 90,
'redirect',
rand() % 100 < 98,
'conversion',
'error'
) AS event_type,
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
multiIf(
rand() % 100 < 60,
'China',
rand() % 100 < 85,
'US',
rand() % 100 < 95,
'Japan',
'Other'
) AS country,
multiIf(
rand() % 100 < 60,
'Beijing',
rand() % 100 < 85,
'New York',
rand() % 100 < 95,
'Tokyo',
'Other'
) AS city,
multiIf(
rand() % 100 < 30,
'https://google.com',
rand() % 100 < 50,
'https://facebook.com',
rand() % 100 < 65,
'https://twitter.com',
rand() % 100 < 75,
'https://instagram.com',
rand() % 100 < 85,
'https://linkedin.com',
rand() % 100 < 90,
'https://bing.com',
rand() % 100 < 95,
'https://baidu.com',
'direct'
) AS referrer,
multiIf(
rand() % 100 < 40,
'google',
rand() % 100 < 70,
'facebook',
rand() % 100 < 90,
'email',
'direct'
) AS utm_source,
multiIf(
rand() % 100 < 40,
'cpc',
rand() % 100 < 70,
'social',
rand() % 100 < 90,
'email',
'direct'
) AS utm_medium,
multiIf(
rand() % 100 < 40,
'summer_sale',
rand() % 100 < 70,
'product_launch',
rand() % 100 < 90,
'newsletter',
'brand'
) AS utm_campaign,
'Mozilla/5.0' AS user_agent,
multiIf(
rand() % 100 < 60,
'mobile',
rand() % 100 < 85,
'desktop',
rand() % 100 < 95,
'tablet',
'other'
) AS device_type,
multiIf(
rand() % 100 < 50,
'Chrome',
rand() % 100 < 80,
'Safari',
rand() % 100 < 95,
'Firefox',
'Edge'
) AS browser,
multiIf(
rand() % 100 < 50,
'iOS',
rand() % 100 < 90,
'Android',
'Windows'
) AS os,
rand() % 400 AS time_spent_sec,
rand() % 100 < 20 AS is_bounce,
rand() % 100 < 25 AS is_qr_scan,
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
multiIf(
rand() % 100 < 50,
'visit',
rand() % 100 < 70,
'stay',
rand() % 100 < 85,
'interact',
rand() % 100 < 93,
'signup',
rand() % 100 < 97,
'subscription',
'purchase'
) AS conversion_type,
rand() % 100 * 3.5 AS conversion_value,
'{}' AS custom_data
FROM
numbers(400);
-- 插入link_daily_stats表数据
INSERT INTO
limq.link_daily_stats (
date,
link_id,
total_clicks,
unique_visitors,
unique_sessions,
total_time_spent,
avg_time_spent,
bounce_count,
conversion_count,
unique_referrers,
mobile_count,
tablet_count,
desktop_count,
qr_scan_count,
total_conversion_value
)
SELECT
subtractDays(today(), number) AS date,
multiIf(
number % 4 = 0,
'11111111-1111-1111-1111-111111111111',
number % 4 = 1,
'22222222-2222-2222-2222-222222222222',
number % 4 = 2,
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444'
) AS link_id,
50 + rand() % 100 AS total_clicks,
30 + rand() % 50 AS unique_visitors,
20 + rand() % 40 AS unique_sessions,
(500 + rand() % 1000) * 60 AS total_time_spent,
(rand() % 10) * 60 + rand() % 60 AS avg_time_spent,
5 + rand() % 20 AS bounce_count,
rand() % 30 AS conversion_count,
3 + rand() % 8 AS unique_referrers,
20 + rand() % 40 AS mobile_count,
5 + rand() % 15 AS tablet_count,
15 + rand() % 30 AS desktop_count,
rand() % 10 AS qr_scan_count,
rand() % 1000 * 2.5 AS total_conversion_value
FROM
numbers(30)
WHERE
number < 30;
-- 插入link_hourly_patterns表数据
INSERT INTO
limq.link_hourly_patterns (date, hour, link_id, visits, unique_visitors)
SELECT
subtractDays(today(), number % 7) AS date,
number % 24 AS hour,
multiIf(
intDiv(number, 24) % 4 = 0,
'11111111-1111-1111-1111-111111111111',
intDiv(number, 24) % 4 = 1,
'22222222-2222-2222-2222-222222222222',
intDiv(number, 24) % 4 = 2,
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444'
) AS link_id,
5 + rand() % 20 AS visits,
3 + rand() % 10 AS unique_visitors
FROM
numbers(672) -- 7天 x 24小时 x 4个链接
WHERE
number < 672;
-- 显示数据行数,验证插入成功
SELECT
'link_events 表行数:' AS metric,
count() AS value
FROM
limq.link_events
UNION
ALL
SELECT
'link_daily_stats 表行数:',
count()
FROM
limq.link_daily_stats
UNION
ALL
SELECT
'link_hourly_patterns 表行数:',
count()
FROM
limq.link_hourly_patterns;

View File

@@ -0,0 +1 @@
./ch-query.sh -q "TRUNCATE TABLE shorturl_analytics.events"

View File

@@ -0,0 +1 @@
./ch-query.sh -q "TRUNCATE TABLE shorturl_analytics.shorturl"

View File

@@ -6,7 +6,7 @@ const config: Config = {
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: 'class',
darkMode: false,
theme: {
extend: {
colors: {

72
test-supabase-login.mjs Normal file
View File

@@ -0,0 +1,72 @@
// 测试Supabase登录功能
import { config } from 'dotenv';
import { createClient } from '@supabase/supabase-js';
// 加载环境变量
config({ path: '.env.local' });
async function testSupabaseLogin() {
// 获取Supabase配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
console.log('Supabase Configuration:');
console.log('- URL defined:', !!supabaseUrl);
console.log('- Key defined:', !!supabaseKey);
console.log('- URL:', supabaseUrl);
if (!supabaseUrl || !supabaseKey) {
console.error('缺少Supabase配置信息请检查.env.local文件');
return;
}
// 创建Supabase客户端
const supabase = createClient(supabaseUrl, supabaseKey);
console.log('Supabase客户端创建成功');
try {
// 尝试获取会话状态
console.log('检查当前会话...');
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
console.error('获取会话失败:', sessionError.message);
} else {
console.log('会话状态:', sessionData.session ? '已登录' : '未登录');
}
// 尝试使用测试账户登录
const testEmail = 'test@example.com';
const testPassword = 'password123';
console.log(`\n尝试使用测试账户登录: ${testEmail}`);
const { data, error } = await supabase.auth.signInWithPassword({
email: testEmail,
password: testPassword
});
if (error) {
console.error('登录失败:', error.message);
// 如果登录失败,尝试注册账户
console.log('\n尝试注册测试账户...');
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email: testEmail,
password: testPassword
});
if (signUpError) {
console.error('注册失败:', signUpError.message);
} else {
console.log('注册成功:', signUpData);
}
} else {
console.log('登录成功!');
console.log('用户信息:', data.user);
}
} catch (error) {
console.error('发生错误:', error.message);
}
}
testSupabaseLogin();

1
types/react-simple-maps.d.ts vendored Normal file
View File

@@ -0,0 +1 @@

146
types/supabase.ts Normal file
View File

@@ -0,0 +1,146 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
teams: {
Row: {
id: string
name: string
description: string | null
attributes: Json | null
created_at: string | null
updated_at: string | null
deleted_at: string | null
schema_version: number | null
avatar_url: string | null
}
Insert: {
id?: string
name: string
description?: string | null
attributes?: Json | null
created_at?: string | null
updated_at?: string | null
deleted_at?: string | null
schema_version?: number | null
avatar_url?: string | null
}
Update: {
id?: string
name?: string
description?: string | null
attributes?: Json | null
created_at?: string | null
updated_at?: string | null
deleted_at?: string | null
schema_version?: number | null
avatar_url?: string | null
}
}
team_membership: {
Row: {
id: string
team_id: string
user_id: string
is_creator: boolean
role: string
}
Insert: {
id?: string
team_id: string
user_id: string
is_creator?: boolean
role: string
}
Update: {
id?: string
team_id?: string
user_id?: string
is_creator?: boolean
role?: string
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
}
limq: {
Tables: {
teams: {
Row: {
id: string
name: string
description: string | null
avatar_url: string | null
attributes: Json | null
created_at: string
updated_at: string
deleted_at: string | null
}
Insert: {
id?: string
name: string
description?: string | null
avatar_url?: string | null
attributes?: Json | null
created_at?: string
updated_at?: string
deleted_at?: string | null
}
Update: {
id?: string
name?: string
description?: string | null
avatar_url?: string | null
attributes?: Json | null
created_at?: string
updated_at?: string
deleted_at?: string | null
}
}
team_membership: {
Row: {
id: string
team_id: string
user_id: string
role: string
created_at: string
updated_at: string
deleted_at: string | null
}
Insert: {
id?: string
team_id: string
user_id: string
role: string
created_at?: string
updated_at?: string
deleted_at?: string | null
}
Update: {
id?: string
team_id?: string
user_id?: string
role?: string
created_at?: string
updated_at?: string
deleted_at?: string | null
}
}
}
}
}

View File

@@ -0,0 +1,522 @@
// 从MongoDB的main.short表同步数据到PostgreSQL的short_url.shorturl表
import { getVariable, setVariable, getResource } from "npm:windmill-client@1";
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
import { Client } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
interface MongoConfig {
host: string;
port: string;
db: string;
username: string;
password: string;
}
interface PostgresConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
schema: string;
}
// 扩展ShortRecord接口以包含更多可能的字段
interface ShortRecord {
_id: ObjectId;
origin: string;
slug: string;
domain: string | null;
createTime: number | { $numberLong: string } | string;
// 可选字段
expiredAt?: number | { $numberLong: string } | string | null;
expiredUrl?: string | null;
password?: string | null;
image?: string | null;
title?: string | null;
description?: string | null;
}
interface SyncState {
last_sync_time: number;
records_synced: number;
last_sync_id?: string;
}
// 同步状态键名
const SYNC_STATE_KEY = "f/limq/mongo_short_to_postgres_shorturl_shorturl_state";
export async function main(
batch_size = 1000,
max_records = 9999999,
timeout_minutes = 60,
skip_duplicate_check = false,
force_insert = false,
reset_sync_state = false,
postgres_schema = "short_url", // 添加schema参数允许运行时指定
postgres_database = "postgres", // 添加数据库名称参数默认为postgres
domain = "upj.to" // 添加domain参数允许用户指定域名
) {
const logWithTimestamp = (message: string) => {
const now = new Date();
console.log(`[${now.toISOString()}] ${message}`);
};
logWithTimestamp("开始执行MongoDB到PostgreSQL的同步任务");
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`);
logWithTimestamp(`使用域名: ${domain}`);
if (skip_duplicate_check) {
logWithTimestamp("⚠️ 警告: 已启用跳过重复检查模式,不会检查记录是否已存在");
}
if (force_insert) {
logWithTimestamp("⚠️ 警告: 已启用强制插入模式,将尝试插入所有记录");
}
if (reset_sync_state) {
logWithTimestamp("⚠️ 警告: 已启用重置同步状态,将从头开始同步数据");
}
// 设置超时
const startTime = Date.now();
const timeoutMs = timeout_minutes * 60 * 1000;
// 检查是否超时
const checkTimeout = () => {
if (Date.now() - startTime > timeoutMs) {
logWithTimestamp(`运行时间超过${timeout_minutes}分钟,暂停执行`);
return true;
}
return false;
};
// 日期解析函数,处理不同格式的日期
const parseDate = (dateValue: any): Date | null => {
if (!dateValue) return null;
// 处理 MongoDB $numberLong 格式
if (dateValue.$numberLong) {
return new Date(Number(dateValue.$numberLong));
}
// 处理普通时间戳
if (typeof dateValue === 'number') {
return new Date(dateValue);
}
// 处理 ISO 字符串格式
if (typeof dateValue === 'string') {
const date = new Date(dateValue);
return isNaN(date.getTime()) ? null : date;
}
return null;
};
// 获取MongoDB和PostgreSQL的连接信息
let mongoConfig: MongoConfig;
let postgresConfig: PostgresConfig;
try {
const rawMongoConfig = await getVariable("f/shorturl_analytics/mongodb");
if (typeof rawMongoConfig === "string") {
try {
mongoConfig = JSON.parse(rawMongoConfig);
} catch (e) {
console.error("MongoDB配置解析失败:", e);
throw e;
}
} else {
mongoConfig = rawMongoConfig as MongoConfig;
}
// 使用getResource获取PostgreSQL资源
try {
logWithTimestamp("正在获取PostgreSQL资源...");
const resourceConfig = await getResource("f/limq/production_supabase");
// 将resource转换为PostgresConfig
postgresConfig = {
host: resourceConfig.host || "",
port: Number(resourceConfig.port) || 5432,
user: resourceConfig.user || "",
password: resourceConfig.password || "",
database: resourceConfig.database || postgres_database, // 使用提供的数据库名称作为备选
schema: resourceConfig.schema || postgres_schema // 使用提供的schema作为备选
};
// 检查并记录配置信息
if (!postgresConfig.database || postgresConfig.database === "undefined") {
postgresConfig.database = postgres_database;
logWithTimestamp(`数据库名称未指定或为"undefined",使用提供的值: ${postgresConfig.database}`);
}
if (!postgresConfig.schema || postgresConfig.schema === "undefined") {
postgresConfig.schema = postgres_schema;
logWithTimestamp(`Schema未指定或为"undefined",使用提供的值: ${postgresConfig.schema}`);
}
logWithTimestamp(`PostgreSQL配置: 数据库=${postgresConfig.database}, Schema=${postgresConfig.schema}`);
} catch (e) {
console.error("获取PostgreSQL资源失败:", e);
throw e;
}
console.log("MongoDB配置:", JSON.stringify({
...mongoConfig,
password: "****" // 隐藏密码
}));
console.log("PostgreSQL配置:", JSON.stringify({
...postgresConfig,
password: "****" // 隐藏密码
}));
} catch (error) {
console.error("获取配置失败:", error);
throw error;
}
// 获取上次同步状态
let lastSyncState: SyncState | null = null;
if (!reset_sync_state) {
try {
const rawSyncState = await getVariable(SYNC_STATE_KEY);
if (rawSyncState) {
if (typeof rawSyncState === "string") {
try {
lastSyncState = JSON.parse(rawSyncState);
} catch (e) {
logWithTimestamp(`解析上次同步状态失败: ${e}, 将从头开始同步`);
}
} else {
lastSyncState = rawSyncState as SyncState;
}
}
} catch (error) {
logWithTimestamp(`获取上次同步状态失败: ${error}, 将从头开始同步`);
}
}
if (lastSyncState) {
logWithTimestamp(`找到上次同步状态: 最后同步时间 ${new Date(lastSyncState.last_sync_time).toISOString()}, 已同步记录数 ${lastSyncState.records_synced}`);
if (lastSyncState.last_sync_id) {
logWithTimestamp(`最后同步ID: ${lastSyncState.last_sync_id}`);
}
} else {
logWithTimestamp("没有找到上次同步状态,将从头开始同步");
}
// 构建MongoDB连接URL
let mongoUrl = "mongodb://";
if (mongoConfig.username && mongoConfig.password) {
mongoUrl += `${mongoConfig.username}:${mongoConfig.password}@`;
}
mongoUrl += `${mongoConfig.host}:${mongoConfig.port}/${mongoConfig.db}`;
console.log(`MongoDB连接URL: ${mongoUrl.replace(/:[^:]*@/, ":****@")}`);
// 构建PostgreSQL连接URL
const pgConnectionString = `postgres://${postgresConfig.user}:${postgresConfig.password}@${postgresConfig.host}:${postgresConfig.port}/${postgresConfig.database}`;
console.log(`PostgreSQL连接URL: ${pgConnectionString.replace(/:[^:]*@/, ":****@")}`);
// 连接MongoDB
const mongoClient = new MongoClient();
let pgClient: Client | null = null;
try {
await mongoClient.connect(mongoUrl);
logWithTimestamp("MongoDB连接成功");
// 连接PostgreSQL
pgClient = new Client(pgConnectionString);
await pgClient.connect();
logWithTimestamp("PostgreSQL连接成功");
// 确认PostgreSQL schema存在
try {
await pgClient.queryArray(`SELECT 1 FROM information_schema.schemata WHERE schema_name = '${postgresConfig.schema}'`);
logWithTimestamp(`PostgreSQL schema '${postgresConfig.schema}' 已确认存在`);
} catch (error) {
logWithTimestamp(`检查PostgreSQL schema失败: ${error}`);
throw new Error(`Schema '${postgresConfig.schema}' 可能不存在`);
}
const db = mongoClient.database(mongoConfig.db);
const shortCollection = db.collection<ShortRecord>("short");
// 构建查询条件,根据上次同步状态获取新记录
const query: Record<string, unknown> = {};
// 如果有上次同步状态,则只获取更新的记录
if (lastSyncState && lastSyncState.last_sync_time) {
// 使用上次同步时间作为过滤条件
query.createTime = { $gt: lastSyncState.last_sync_time };
logWithTimestamp(`将只同步createTime > ${lastSyncState.last_sync_time} (${new Date(lastSyncState.last_sync_time).toISOString()}) 的记录`);
}
// 计算总记录数
const totalRecords = await shortCollection.countDocuments(query);
logWithTimestamp(`找到 ${totalRecords} 条新记录需要同步`);
// 限制此次处理的记录数量
const recordsToProcess = Math.min(totalRecords, max_records);
logWithTimestamp(`本次将处理 ${recordsToProcess} 条记录`);
if (totalRecords === 0) {
logWithTimestamp("没有新记录需要同步,任务完成");
return {
success: true,
records_synced: 0,
message: "没有新记录需要同步"
};
}
// 检查记录是否已经存在于PostgreSQL中
const checkExistingRecords = async (records: ShortRecord[]): Promise<ShortRecord[]> => {
if (records.length === 0) return [];
// 如果跳过重复检查或强制插入,则直接返回所有记录
if (skip_duplicate_check || force_insert) {
logWithTimestamp(`已跳过重复检查,准备处理所有 ${records.length} 条记录`);
return records;
}
logWithTimestamp(`正在检查 ${records.length} 条记录是否已存在于PostgreSQL中...`);
try {
// 提取所有记录的slugs
const slugs = records.map(record => record.slug);
// 查询PostgreSQL中是否已存在这些slugs
const result = await pgClient!.queryArray(`
SELECT slug FROM ${postgresConfig.schema}.shorturl
WHERE slug = ANY($1::text[])
`, [slugs]);
// 将已存在的slugs加入到集合中
const existingSlugs = new Set<string>();
for (const row of result.rows) {
existingSlugs.add(row[0] as string);
}
logWithTimestamp(`检测到 ${existingSlugs.size} 条记录已存在于PostgreSQL中`);
// 过滤出不存在的记录
const newRecords = records.filter(record => !existingSlugs.has(record.slug));
logWithTimestamp(`过滤后剩余 ${newRecords.length} 条新记录需要插入`);
return newRecords;
} catch (err) {
const error = err as Error;
logWithTimestamp(`PostgreSQL查询出错: ${error.message}`);
if (skip_duplicate_check) {
logWithTimestamp("已启用跳过重复检查,将继续处理所有记录");
return records;
} else {
throw error;
}
}
};
// 处理记录的函数
const processRecords = async (records: ShortRecord[]) => {
if (records.length === 0) return 0;
logWithTimestamp(`开始处理批次数据,共 ${records.length} 条记录...`);
// 检查记录是否已存在
let newRecords;
try {
newRecords = await checkExistingRecords(records);
} catch (err) {
const error = err as Error;
logWithTimestamp(`检查记录是否存在时出错: ${error.message}`);
if (!skip_duplicate_check && !force_insert) {
throw error;
}
// 如果跳过检查或强制插入,则使用所有记录
logWithTimestamp("将使用所有记录进行处理");
newRecords = records;
}
if (newRecords.length === 0) {
logWithTimestamp("所有记录都已存在,跳过处理");
return 0;
}
logWithTimestamp(`准备处理 ${newRecords.length} 条新记录...`);
// 批量插入PostgreSQL
try {
// 开始事务
await pgClient!.queryArray('BEGIN');
let insertedCount = 0;
// 由于参数可能很多,按小批次处理
const smallBatchSize = 100;
for (let i = 0; i < newRecords.length; i += smallBatchSize) {
const batchRecords = newRecords.slice(i, i + smallBatchSize);
// 构造批量插入语句
const placeholders = [];
const values = [];
let valueIndex = 1;
for (const record of batchRecords) {
// 参考提供的字段处理方式处理数据
const createdAt = parseDate(record.createTime);
const updatedAt = createdAt; // 设置更新时间等于创建时间
const fullShortUrl = `${domain}/${record.slug}`;
placeholders.push(`($${valueIndex}, $${valueIndex+1}, $${valueIndex+2}, $${valueIndex+3}, $${valueIndex+4}, $${valueIndex+5}, $${valueIndex+6}, $${valueIndex+7}, $${valueIndex+8}, $${valueIndex+9}, $${valueIndex+10}, $${valueIndex+11}, $${valueIndex+12})`);
values.push(
record._id.toString(), // id
record.slug, // slug
domain, // domain (使用提供的域名)
record.slug, // name (使用slug作为name)
record.slug, // title (使用slug作为title)
record.origin || '', // origin
createdAt, // created_at
updatedAt, // updated_at
fullShortUrl, // full_short_url
record.image || null, // image
record.description || null, // description
record.expiredUrl || null, // expired_url
parseDate(record.expiredAt) // expired_at
);
valueIndex += 13;
}
const query = `
INSERT INTO ${postgresConfig.schema}.shorturl
(id, slug, domain, name, title, origin, created_at, updated_at, full_short_url, image, description, expired_url, expired_at)
VALUES ${placeholders.join(', ')}
`;
await pgClient!.queryArray(query, values);
insertedCount += batchRecords.length;
logWithTimestamp(`已插入 ${insertedCount}/${newRecords.length} 条记录`);
}
// 提交事务
await pgClient!.queryArray('COMMIT');
logWithTimestamp(`成功插入 ${insertedCount} 条记录到PostgreSQL`);
return insertedCount;
} catch (err) {
const error = err as Error;
// 发生错误,回滚事务
await pgClient!.queryArray('ROLLBACK');
logWithTimestamp(`向PostgreSQL插入数据失败: ${error.message}`);
throw error;
}
};
// 批量处理记录
let processedRecords = 0;
let totalBatchRecords = 0;
let lastSyncTime = 0;
let lastSyncId = "";
for (let page = 0; processedRecords < recordsToProcess; page++) {
// 检查超时
if (checkTimeout()) {
logWithTimestamp(`已处理 ${processedRecords}/${recordsToProcess} 条记录,因超时暂停执行`);
break;
}
// 每批次都输出进度
logWithTimestamp(`开始处理第 ${page+1} 批次,已完成 ${processedRecords}/${recordsToProcess} 条记录 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
logWithTimestamp(`正在从MongoDB获取第 ${page+1} 批次数据...`);
const records = await shortCollection.find(
query,
{
sort: { createTime: 1 },
skip: page * batch_size,
limit: batch_size
}
).toArray();
if (records.length === 0) {
logWithTimestamp("没有找到更多数据,同步结束");
break;
}
// 找到数据,开始处理
logWithTimestamp(`获取到 ${records.length} 条记录,开始处理...`);
// 输出当前批次的部分数据信息
if (records.length > 0) {
logWithTimestamp(`批次 ${page+1} 第一条记录: ID=${records[0]._id}, slug=${records[0].slug}, 时间=${new Date(typeof records[0].createTime === 'number' ? records[0].createTime : 0).toISOString()}`);
if (records.length > 1) {
const lastRec = records[records.length-1];
logWithTimestamp(`批次 ${page+1} 最后一条记录: ID=${lastRec._id}, slug=${lastRec.slug}, 时间=${new Date(typeof lastRec.createTime === 'number' ? lastRec.createTime : 0).toISOString()}`);
}
}
const batchSize = await processRecords(records);
processedRecords += records.length;
totalBatchRecords += batchSize;
// 更新最后处理的记录时间和ID
if (records.length > 0) {
const lastRecord = records[records.length - 1];
// 提取数字时间戳
let lastCreateTime = 0;
if (typeof lastRecord.createTime === 'number') {
lastCreateTime = lastRecord.createTime;
} else if (lastRecord.createTime && lastRecord.createTime.$numberLong) {
lastCreateTime = Number(lastRecord.createTime.$numberLong);
}
lastSyncTime = Math.max(lastSyncTime, lastCreateTime);
lastSyncId = lastRecord._id.toString();
}
logWithTimestamp(`${page+1} 批次处理完成。已处理 ${processedRecords}/${recordsToProcess} 条记录,实际插入 ${totalBatchRecords} 条 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
}
// 更新同步状态
if (processedRecords > 0 && lastSyncTime > 0) {
// 创建新的同步状态
const newSyncState: SyncState = {
last_sync_time: lastSyncTime,
records_synced: (lastSyncState ? lastSyncState.records_synced : 0) + totalBatchRecords,
last_sync_id: lastSyncId
};
try {
// 保存同步状态
await setVariable(SYNC_STATE_KEY, newSyncState);
logWithTimestamp(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 累计同步记录数 ${newSyncState.records_synced}`);
} catch (err) {
const error = err as Error;
logWithTimestamp(`更新同步状态失败: ${error.message}`);
}
}
return {
success: true,
records_processed: processedRecords,
records_synced: totalBatchRecords,
last_sync_time: lastSyncTime > 0 ? new Date(lastSyncTime).toISOString() : null,
message: "数据同步完成"
};
} catch (err) {
const error = err as Error;
console.error("同步过程中发生错误:", error);
return {
success: false,
error: error.message,
stack: error.stack
};
} finally {
// 关闭连接
if (pgClient) {
await pgClient.end();
logWithTimestamp("PostgreSQL连接已关闭");
}
await mongoClient.close();
logWithTimestamp("MongoDB连接已关闭");
}
}

Some files were not shown because too many files have changed in this diff Show More