Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b41f3ea42 | |||
| 63f434fd93 | |||
| 95f230b996 | |||
| 0f8419778c | |||
| a6f7172ec4 | |||
| 8054b0235d | |||
| b0dbd088e7 | |||
| bf7c62fdc9 | |||
| 9cb9f62686 | |||
| 4b7fb7a887 | |||
| bdae5c164c | |||
| 9fa61ccf8d | |||
| b187bdefdf | |||
| 87c3803236 | |||
| 75adb36111 | |||
| a4ef2c3147 | |||
| 57e16144a9 | |||
| 1be6a6dbf0 | |||
| 36f22059e9 | |||
| a8d364be1f | |||
| 326a6c6d63 | |||
| 0a881fd180 | |||
| 1b901bda90 | |||
| 53822f1087 | |||
| 1978e0224e | |||
| c0649ce10f | |||
| 696a434b95 | |||
| b8e6180212 | |||
| 6beb6c3666 | |||
| 17b588e249 | |||
| 26db8fe76d | |||
| 4ad505cda1 | |||
| 7a03396cdd | |||
| e9b9950ed3 | |||
| f5b14bf936 | |||
| ca8a7d56f1 | |||
| 913c9cd289 | |||
| e916eab92c | |||
| 63a578ef38 | |||
| b4aa765c17 | |||
| c0e5a9ccb2 | |||
| 1755b44a39 | |||
| e0ac87fb25 | |||
| ecf21a812f | |||
| efdfe8bf8e | |||
| 92d82b18a0 | |||
| 1e9e5928d7 | |||
| 231cf556b0 | |||
| 3413d3e182 |
47
Date Format Handling for ClickHouse Events API.md
Normal file
47
Date Format Handling for ClickHouse Events API.md
Normal 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
28
app/(app)/layout.tsx
Normal 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
65
app/(app)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
app/api/events/devices/route.ts
Normal file
37
app/api/events/devices/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
41
app/api/events/geo/route.ts
Normal file
41
app/api/events/geo/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
69
app/api/events/route.ts
Normal file
69
app/api/events/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
|
||||
// 获取可能存在的多个团队、项目和标签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); // 添加日志便于调试
|
||||
|
||||
// 获取事件列表
|
||||
const params: EventsQueryParams = {
|
||||
page,
|
||||
pageSize,
|
||||
eventType,
|
||||
linkId,
|
||||
linkSlug,
|
||||
userId,
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
startTime,
|
||||
endTime,
|
||||
sortBy,
|
||||
sortOrder
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
36
app/api/events/summary/route.ts
Normal file
36
app/api/events/summary/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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');
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
47
app/api/events/time-series/route.ts
Normal file
47
app/api/events/time-series/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
137
app/api/events/track/route.ts
Normal file
137
app/api/events/track/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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 || '',
|
||||
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
248
app/api/geo/batch/route.ts
Normal file
248
app/api/geo/batch/route.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
41
app/api/teams/list/route.ts
Normal file
41
app/api/teams/list/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
343
app/api/types.ts
343
app/api/types.ts
@@ -1,221 +1,166 @@
|
||||
// 链接数据类型
|
||||
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;
|
||||
|
||||
// 交互信息
|
||||
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
111
app/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/components/analytics/DeviceAnalytics.tsx
Normal file
52
app/components/analytics/DeviceAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
app/components/analytics/GeoAnalytics.tsx
Normal file
274
app/components/analytics/GeoAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
app/components/charts/DevicePieCharts.tsx
Normal file
211
app/components/charts/DevicePieCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
app/components/charts/TimeSeriesChart.tsx
Normal file
184
app/components/charts/TimeSeriesChart.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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 `${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as ChartOptions<'line'>
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<canvas ref={chartRef} />
|
||||
);
|
||||
}
|
||||
100
app/components/ipLocationTest.tsx
Normal file
100
app/components/ipLocationTest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/components/layout/Header.tsx
Normal file
51
app/components/layout/Header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'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="/" 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
84
app/components/ui/DateRangePicker.tsx
Normal file
84
app/components/ui/DateRangePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
app/components/ui/ProjectSelector.tsx
Normal file
363
app/components/ui/ProjectSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
app/components/ui/Select.tsx
Normal file
88
app/components/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
app/components/ui/TagSelector.tsx
Normal file
321
app/components/ui/TagSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
app/components/ui/TeamSelector.tsx
Normal file
258
app/components/ui/TeamSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
10
app/ip-test/page.tsx
Normal file
10
app/ip-test/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import IpLocationTest from '../components/ipLocationTest';
|
||||
|
||||
export default function IpTestPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
|
||||
<IpLocationTest />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +17,14 @@ 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,548 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
||||
import { Link, StatsOverview, Tag } from '../api/types';
|
||||
|
||||
// Define type for link data
|
||||
interface LinkData {
|
||||
name: string;
|
||||
originalUrl: string;
|
||||
customSlug: string;
|
||||
expiresAt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 映射API数据到UI所需格式
|
||||
interface UILink {
|
||||
id: string;
|
||||
name: string;
|
||||
shortUrl: string;
|
||||
originalUrl: string;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
visits: number;
|
||||
visitChange: number;
|
||||
uniqueVisitors: number;
|
||||
uniqueVisitorsChange: number;
|
||||
avgTime: string;
|
||||
avgTimeChange: number;
|
||||
conversionRate: number;
|
||||
conversionChange: number;
|
||||
status: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function LinksPage() {
|
||||
const [links, setLinks] = useState<UILink[]>([]);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [stats, setStats] = useState<StatsOverview>({
|
||||
totalLinks: 0,
|
||||
activeLinks: 0,
|
||||
totalVisits: 0,
|
||||
conversionRate: 0
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 无限加载相关状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
const lastLinkElementRef = useRef<HTMLTableRowElement | null>(null);
|
||||
|
||||
// 映射API数据到UI所需格式的函数
|
||||
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
|
||||
// 生成短URL显示 - 因为数据库中没有short_url字段
|
||||
const shortUrlDisplay = generateShortUrlDisplay(apiLink.link_id, apiLink.original_url);
|
||||
|
||||
return {
|
||||
id: apiLink.link_id,
|
||||
name: apiLink.title || 'Untitled Link',
|
||||
shortUrl: shortUrlDisplay,
|
||||
originalUrl: apiLink.original_url,
|
||||
creator: apiLink.created_by,
|
||||
createdAt: new Date(apiLink.created_at).toLocaleDateString(),
|
||||
visits: apiLink.visits,
|
||||
visitChange: 0, // API doesn't provide change data yet
|
||||
uniqueVisitors: apiLink.unique_visits,
|
||||
uniqueVisitorsChange: 0,
|
||||
avgTime: '0m 0s', // API doesn't provide average time yet
|
||||
avgTimeChange: 0,
|
||||
conversionRate: 0, // API doesn't provide conversion rate yet
|
||||
conversionChange: 0,
|
||||
status: apiLink.is_active ? 'active' : 'inactive',
|
||||
tags: apiLink.tags || []
|
||||
};
|
||||
};
|
||||
|
||||
// 从link_id和原始URL生成短URL显示
|
||||
const generateShortUrlDisplay = (linkId: string, originalUrl: string): string => {
|
||||
try {
|
||||
// 尝试从原始URL提取域名
|
||||
const urlObj = new URL(originalUrl);
|
||||
const domain = urlObj.hostname.replace('www.', '');
|
||||
|
||||
// 使用link_id的前8个字符作为短代码
|
||||
const shortCode = linkId.substring(0, 8);
|
||||
|
||||
return `${domain}/${shortCode}`;
|
||||
} catch {
|
||||
// 如果URL解析失败,返回一个基于linkId的默认值
|
||||
return `short.link/${linkId.substring(0, 8)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取链接数据
|
||||
const fetchLinks = useCallback(async (pageNum: number, isInitialLoad: boolean = false) => {
|
||||
try {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// 获取链接列表
|
||||
const linksResponse = await fetch(`/api/links?page=${pageNum}&limit=20${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}`);
|
||||
if (!linksResponse.ok) {
|
||||
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
|
||||
}
|
||||
const linksData = await linksResponse.json();
|
||||
|
||||
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
|
||||
|
||||
if (isInitialLoad) {
|
||||
setLinks(uiLinks);
|
||||
} else {
|
||||
setLinks(prevLinks => [...prevLinks, ...uiLinks]);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据可加载
|
||||
const { pagination } = linksData;
|
||||
setHasMore(pagination.page < pagination.totalPages);
|
||||
|
||||
if (isInitialLoad) {
|
||||
// 只在初始加载时获取标签和统计数据
|
||||
const tagsResponse = await fetch('/api/tags');
|
||||
if (!tagsResponse.ok) {
|
||||
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
|
||||
}
|
||||
const tagsData = await tagsResponse.json();
|
||||
|
||||
const statsResponse = await fetch('/api/stats');
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
|
||||
}
|
||||
const statsData = await statsResponse.json();
|
||||
|
||||
setAllTags(tagsData);
|
||||
setStats(statsData);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Data loading failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
fetchLinks(1, true);
|
||||
}, [fetchLinks]);
|
||||
|
||||
// 搜索过滤变化时重新加载数据
|
||||
useEffect(() => {
|
||||
// 当搜索关键词变化时,重置页码和链接列表,然后重新获取数据
|
||||
setLinks([]);
|
||||
setPage(1);
|
||||
fetchLinks(1, true);
|
||||
}, [searchQuery, fetchLinks]);
|
||||
|
||||
// 设置Intersection Observer来检测滚动并加载更多数据
|
||||
useEffect(() => {
|
||||
// 如果正在加载或没有更多数据,则不设置observer
|
||||
if (isLoading || isLoadingMore || !hasMore) return;
|
||||
|
||||
// 断开之前的observer连接
|
||||
if (observer.current) {
|
||||
observer.current.disconnect();
|
||||
}
|
||||
|
||||
observer.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
// 当最后一个元素可见且有更多数据时,加载下一页
|
||||
setPage(prevPage => prevPage + 1);
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
if (lastLinkElementRef.current) {
|
||||
observer.current.observe(lastLinkElementRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer.current) {
|
||||
observer.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [isLoading, isLoadingMore, hasMore, links]);
|
||||
|
||||
// 当页码变化时加载更多数据
|
||||
useEffect(() => {
|
||||
if (page > 1) {
|
||||
fetchLinks(page, false);
|
||||
}
|
||||
}, [page, fetchLinks]);
|
||||
|
||||
const filteredLinks = links.filter(link =>
|
||||
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.shortUrl.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleOpenLinkDetails = (id: string) => {
|
||||
window.location.href = `/links/${id}`;
|
||||
};
|
||||
|
||||
const handleCreateLink = async (linkData: LinkData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// 在实际应用中,这里会发送 POST 请求到 API
|
||||
console.log('创建链接:', linkData);
|
||||
|
||||
// 刷新链接列表
|
||||
setPage(1);
|
||||
fetchLinks(1, true);
|
||||
|
||||
setShowCreateModal(false);
|
||||
} catch (err) {
|
||||
console.error('创建链接失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
if (isLoading && links.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="p-4 text-center">
|
||||
<div className="w-12 h-12 mx-auto border-4 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
||||
<p className="mt-4 text-lg text-foreground">Loading data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && links.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="p-6 text-center rounded-lg bg-red-500/10">
|
||||
<svg className="w-12 h-12 mx-auto text-accent-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="mt-4 text-xl font-bold text-foreground">Loading Failed</h2>
|
||||
<p className="mt-2 text-text-secondary">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 mt-4 text-white rounded-lg bg-accent-blue hover:bg-blue-600"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container px-4 py-8 mx-auto">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:justify-between md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Link Management</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
View and manage all your shortened links
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
className="block w-full p-2.5 pl-10 text-sm border rounded-lg bg-card-bg border-card-border text-foreground placeholder-text-secondary focus:ring-accent-blue focus:border-accent-blue"
|
||||
placeholder="Search links..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2.5 bg-accent-blue text-white rounded-lg text-sm font-medium hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
New Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-blue bg-blue-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Total Links</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.totalLinks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-green bg-green-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Active Links</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.activeLinks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-purple bg-purple-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Total Visits</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.totalVisits.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full bg-amber-500/10 text-accent-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Conversion Rate</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{(stats.conversionRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Table */}
|
||||
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-card-border">
|
||||
<thead className="bg-card-bg-secondary">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3">Link Info</th>
|
||||
<th scope="col" className="px-6 py-3">Visits</th>
|
||||
<th scope="col" className="px-6 py-3">Unique Visitors</th>
|
||||
<th scope="col" className="px-6 py-3">Avg Time</th>
|
||||
<th scope="col" className="px-6 py-3">Conversion</th>
|
||||
<th scope="col" className="px-6 py-3">Status</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-card-border">
|
||||
{filteredLinks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-text-secondary">
|
||||
No links found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLinks.map((link, index) => (
|
||||
<tr
|
||||
key={link.id}
|
||||
onClick={() => handleOpenLinkDetails(link.id)}
|
||||
className="transition-colors cursor-pointer hover:bg-card-bg-secondary"
|
||||
ref={index === filteredLinks.length - 1 ? lastLinkElementRef : null}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.name}</div>
|
||||
<div className="text-xs text-accent-blue">{link.shortUrl}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.visits.toLocaleString()}</div>
|
||||
<div className={`text-xs flex items-center ${link.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.visitChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.visitChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.uniqueVisitors.toLocaleString()}</div>
|
||||
<div className={`text-xs flex items-center ${link.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.uniqueVisitorsChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.uniqueVisitorsChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.avgTime}</div>
|
||||
<div className={`text-xs flex items-center ${link.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.avgTimeChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.avgTimeChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.conversionRate}%</div>
|
||||
<div className={`text-xs flex items-center ${link.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.conversionChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.conversionChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
link.status === 'active'
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: link.status === 'inactive'
|
||||
? 'bg-gray-500/10 text-text-secondary'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
{link.status === 'active' ? 'Active' : link.status === 'inactive' ? 'Inactive' : 'Expired'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenLinkDetails(link.id);
|
||||
}}
|
||||
className="text-sm font-medium text-accent-blue hover:underline"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{isLoadingMore && (
|
||||
<div className="p-4 text-center">
|
||||
<div className="inline-block w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
||||
<p className="mt-2 text-sm text-text-secondary">Loading more links...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of results message */}
|
||||
{!hasMore && links.length > 0 && (
|
||||
<div className="p-4 text-center text-sm text-text-secondary">
|
||||
No more links to load.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags Section */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<h2 className="mb-4 text-lg font-medium text-foreground">Tags</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map(tagItem => (
|
||||
<span
|
||||
key={tagItem.tag}
|
||||
className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full text-accent-blue bg-blue-500/10"
|
||||
onClick={() => setSearchQuery(tagItem.tag)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{tagItem.tag}
|
||||
<span className="ml-1.5 text-xs bg-blue-500/20 px-1.5 py-0.5 rounded-full">
|
||||
{tagItem.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Link Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateLinkModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
app/login/page.tsx
Normal file
221
app/login/page.tsx
Normal 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't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
804
app/page.tsx
804
app/page.tsx
@@ -1,5 +1,803 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
"use client";
|
||||
|
||||
export default function Home() {
|
||||
redirect('/links');
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||
import TimeSeriesChart from '@/app/components/charts/TimeSeriesChart';
|
||||
import GeoAnalytics from '@/app/components/analytics/GeoAnalytics';
|
||||
import DevicePieCharts from '@/app/components/charts/DevicePieCharts';
|
||||
import { EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
|
||||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||||
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
|
||||
import { TagSelector } from '@/app/components/ui/TagSelector';
|
||||
|
||||
// 事件类型定义
|
||||
interface Event {
|
||||
event_id?: string;
|
||||
url_id: string;
|
||||
url: string;
|
||||
event_type: string;
|
||||
visitor_id: string;
|
||||
created_at: string;
|
||||
event_time?: string;
|
||||
referrer?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
device_type?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
event_attributes?: string;
|
||||
link_attributes?: string;
|
||||
user_attributes?: string;
|
||||
link_label?: string;
|
||||
link_original_url?: string;
|
||||
team_name?: string;
|
||||
project_name?: string;
|
||||
link_id?: string;
|
||||
link_slug?: string;
|
||||
link_tags?: string;
|
||||
ip_address?: string;
|
||||
}
|
||||
|
||||
// 格式化日期函数
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析JSON字符串
|
||||
const parseJsonSafely = (jsonString: string) => {
|
||||
if (!jsonString) return null;
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户可读名称
|
||||
const getUserDisplayName = (user: Record<string, unknown> | null) => {
|
||||
if (!user) return '-';
|
||||
if (typeof user.full_name === 'string') return user.full_name;
|
||||
if (typeof user.name === 'string') return user.name;
|
||||
if (typeof user.email === 'string') return user.email;
|
||||
return '-';
|
||||
};
|
||||
|
||||
// 提取链接和事件的重要信息
|
||||
const extractEventInfo = (event: Event) => {
|
||||
// 解析事件属性
|
||||
const eventAttrs = parseJsonSafely(event.event_attributes || '{}');
|
||||
|
||||
// 解析链接属性
|
||||
const linkAttrs = parseJsonSafely(event.link_attributes || '{}');
|
||||
|
||||
// 解析用户属性
|
||||
const userAttrs = parseJsonSafely(event.user_attributes || '{}');
|
||||
|
||||
// 解析标签信息
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
if (event.link_tags) {
|
||||
const parsedTags = JSON.parse(event.link_tags);
|
||||
if (Array.isArray(parsedTags)) {
|
||||
tags = parsedTags;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败则保持空数组
|
||||
}
|
||||
|
||||
return {
|
||||
eventTime: event.created_at || event.event_time,
|
||||
linkName: event.link_label || linkAttrs?.name || eventAttrs?.link_name || event.link_slug || '-',
|
||||
originalUrl: event.link_original_url || eventAttrs?.origin_url || '-',
|
||||
eventType: event.event_type || '-',
|
||||
visitorId: event.visitor_id?.substring(0, 8) || '-',
|
||||
referrer: eventAttrs?.referrer || '-',
|
||||
ipAddress: event.ip_address || '-',
|
||||
location: event.country ? (event.city ? `${event.city}, ${event.country}` : event.country) : '-',
|
||||
device: event.device_type || '-',
|
||||
browser: event.browser || '-',
|
||||
os: event.os || '-',
|
||||
userInfo: getUserDisplayName(userAttrs),
|
||||
teamName: event.team_name || '-',
|
||||
projectName: event.project_name || '-',
|
||||
tags: tags
|
||||
};
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
// 默认日期范围为最近7天
|
||||
const today = new Date();
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: subDays(today, 7), // 7天前
|
||||
to: today // 今天
|
||||
});
|
||||
|
||||
// 添加团队选择状态 - 使用数组支持多选
|
||||
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
|
||||
// 添加项目选择状态 - 使用数组支持多选
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
// 添加标签选择状态 - 使用数组支持多选
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
// 添加分页状态
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [totalEvents, setTotalEvents] = useState<number>(0);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<EventsSummary | null>(null);
|
||||
const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData[]>([]);
|
||||
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
||||
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
|
||||
// 构建基础URL和查询参数
|
||||
const baseUrl = '/api/events';
|
||||
const params = new URLSearchParams({
|
||||
startTime,
|
||||
endTime,
|
||||
page: currentPage.toString(),
|
||||
pageSize: pageSize.toString()
|
||||
});
|
||||
|
||||
// 添加团队ID参数 - 支持多个团队
|
||||
if (selectedTeamIds.length > 0) {
|
||||
selectedTeamIds.forEach(teamId => {
|
||||
params.append('teamId', teamId);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加项目ID参数 - 支持多个项目
|
||||
if (selectedProjectIds.length > 0) {
|
||||
selectedProjectIds.forEach(projectId => {
|
||||
params.append('projectId', projectId);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标签ID参数 - 支持多个标签
|
||||
if (selectedTagIds.length > 0) {
|
||||
selectedTagIds.forEach(tagId => {
|
||||
params.append('tagId', tagId);
|
||||
});
|
||||
}
|
||||
|
||||
// 并行获取所有数据
|
||||
const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([
|
||||
fetch(`${baseUrl}/summary?${params.toString()}`),
|
||||
fetch(`${baseUrl}/time-series?${params.toString()}`),
|
||||
fetch(`${baseUrl}/geo?${params.toString()}`),
|
||||
fetch(`${baseUrl}/devices?${params.toString()}`),
|
||||
fetch(`${baseUrl}?${params.toString()}`)
|
||||
]);
|
||||
|
||||
const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([
|
||||
summaryRes.json(),
|
||||
timeSeriesRes.json(),
|
||||
geoRes.json(),
|
||||
deviceRes.json(),
|
||||
eventsRes.json()
|
||||
]);
|
||||
|
||||
if (!summaryRes.ok) throw new Error(summaryData.error || 'Failed to fetch summary data');
|
||||
if (!timeSeriesRes.ok) throw new Error(timeSeriesData.error || 'Failed to fetch time series data');
|
||||
if (!geoRes.ok) throw new Error(geoData.error || 'Failed to fetch geo data');
|
||||
if (!deviceRes.ok) throw new Error(deviceData.error || 'Failed to fetch device data');
|
||||
if (!eventsRes.ok) throw new Error(eventsData.error || 'Failed to fetch events data');
|
||||
|
||||
setSummary(summaryData.data);
|
||||
setTimeSeriesData(timeSeriesData.data);
|
||||
setGeoData(geoData.data);
|
||||
setDeviceData(deviceData.data);
|
||||
setEvents(eventsData.data || []);
|
||||
|
||||
// 设置总事件数量用于分页
|
||||
if (eventsData.meta) {
|
||||
// 确保将total转换为数字,无论它是字符串还是数字
|
||||
const totalCount = parseInt(String(eventsData.meta.total), 10);
|
||||
if (!isNaN(totalCount)) {
|
||||
setTotalEvents(totalCount);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagIds, currentPage, pageSize]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<TeamSelector
|
||||
value={selectedTeamIds}
|
||||
onChange={(value) => {
|
||||
const newTeamIds = Array.isArray(value) ? value : [value];
|
||||
|
||||
// Check if team selection has changed
|
||||
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
|
||||
// Clear project selection when team changes
|
||||
setSelectedProjectIds([]);
|
||||
|
||||
// Update team selection
|
||||
setSelectedTeamIds(newTeamIds);
|
||||
}
|
||||
}}
|
||||
className="w-[250px]"
|
||||
multiple={true}
|
||||
/>
|
||||
<ProjectSelector
|
||||
value={selectedProjectIds}
|
||||
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
||||
className="w-[250px]"
|
||||
multiple={true}
|
||||
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||
/>
|
||||
<TagSelector
|
||||
value={selectedTagIds}
|
||||
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
|
||||
className="w-[250px]"
|
||||
multiple={true}
|
||||
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||
/>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 显示团队选择信息 */}
|
||||
{selectedTeamIds.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||
<span className="text-blue-700 font-medium mr-2">
|
||||
{selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTeamIds.map(teamId => (
|
||||
<span key={teamId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
{teamId}
|
||||
<button
|
||||
onClick={() => setSelectedTeamIds(selectedTeamIds.filter(id => id !== teamId))}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedTeamIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTeamIds([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示项目选择信息 */}
|
||||
{selectedProjectIds.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||
<span className="text-blue-700 font-medium mr-2">
|
||||
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedProjectIds.map(projectId => (
|
||||
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
{projectId}
|
||||
<button
|
||||
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedProjectIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedProjectIds([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示标签选择信息 */}
|
||||
{selectedTagIds.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||
<span className="text-blue-700 font-medium mr-2">
|
||||
{selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTagIds.map(tagName => (
|
||||
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
{tagName}
|
||||
<button
|
||||
onClick={() => setSelectedTagIds(selectedTagIds.filter(name => name !== tagName))}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTagIds([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 仪表板内容 - 现在放在事件列表之后 */}
|
||||
<>
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
|
||||
</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">
|
||||
Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Link Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Original URL
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Event Type
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tags
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Team/Project
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP/Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Device Info
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{events.map((event, index) => {
|
||||
const info = extractEventInfo(event);
|
||||
return (
|
||||
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(info.eventTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<span className="font-medium">{info.linkName}</span>
|
||||
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
|
||||
ID: {event.link_id?.substring(0, 8) || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">
|
||||
<a href={info.originalUrl} className="hover:underline truncate max-w-xs block" target="_blank" rel="noopener noreferrer">
|
||||
{info.originalUrl}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
info.eventType === 'click'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{info.eventType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.tags && info.tags.length > 0 ? (
|
||||
info.tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="font-medium">{info.userInfo}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{info.visitorId}...</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="font-medium">{info.teamName}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{info.projectName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs inline-flex items-center mb-1">
|
||||
<span className="font-medium">IP:</span>
|
||||
<span className="ml-1">{info.ipAddress}</span>
|
||||
</span>
|
||||
<span className="text-xs inline-flex items-center">
|
||||
<span className="font-medium">Location:</span>
|
||||
<span className="ml-1">{info.location}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs inline-flex items-center mb-1">
|
||||
<span className="font-medium">Device:</span>
|
||||
<span className="ml-1">{info.device}</span>
|
||||
</span>
|
||||
<span className="text-xs inline-flex items-center mb-1">
|
||||
<span className="font-medium">Browser:</span>
|
||||
<span className="ml-1">{info.browser}</span>
|
||||
</span>
|
||||
<span className="text-xs inline-flex items-center">
|
||||
<span className="font-medium">OS:</span>
|
||||
<span className="ml-1">{info.os}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 表格为空状态 */}
|
||||
{!loading && events.length === 0 && (
|
||||
<div className="flex justify-center items-center p-8 text-gray-500">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页控件 - 删除totalEvents > 0条件,改为events.length > 0 */}
|
||||
{!loading && events.length > 0 && (
|
||||
<div className="px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
currentPage === 1
|
||||
? 'text-gray-300 bg-gray-50'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
|
||||
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||
className={`ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0}</span> to <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0}</span> of{' '}
|
||||
<span className="font-medium">{totalEvents}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-4">
|
||||
<select
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
}}
|
||||
>
|
||||
<option value="5">5 / page</option>
|
||||
<option value="10">10 / page</option>
|
||||
<option value="20">20 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 添加直接跳转到指定页的输入框 */}
|
||||
<div className="mr-4 flex items-center">
|
||||
<span className="text-sm text-gray-700 mr-2">Go to:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={Math.max(1, Math.ceil(totalEvents / pageSize))}
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
const page = parseInt(e.target.value);
|
||||
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const page = parseInt(input.value);
|
||||
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 ml-2">
|
||||
of {Math.max(1, Math.ceil(totalEvents / pageSize))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{/* 首页按钮 */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${
|
||||
currentPage === 1
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">First</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414zm-6 0a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 011.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 上一页按钮 */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
|
||||
currentPage === 1
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 页码按钮 */}
|
||||
{(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalEvents / pageSize));
|
||||
const pageNumbers = [];
|
||||
|
||||
// 如果总页数小于等于7,显示所有页码
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 总是显示首页
|
||||
pageNumbers.push(1);
|
||||
|
||||
// 根据当前页显示中间页码
|
||||
if (currentPage <= 3) {
|
||||
// 当前页靠近开始
|
||||
pageNumbers.push(2, 3, 4);
|
||||
pageNumbers.push('ellipsis1');
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
// 当前页靠近结束
|
||||
pageNumbers.push('ellipsis1');
|
||||
pageNumbers.push(totalPages - 3, totalPages - 2, totalPages - 1);
|
||||
} else {
|
||||
// 当前页在中间
|
||||
pageNumbers.push('ellipsis1');
|
||||
pageNumbers.push(currentPage - 1, currentPage, currentPage + 1);
|
||||
pageNumbers.push('ellipsis2');
|
||||
}
|
||||
|
||||
// 总是显示尾页
|
||||
pageNumbers.push(totalPages);
|
||||
}
|
||||
|
||||
return pageNumbers.map((pageNum, idx) => {
|
||||
if (pageNum === 'ellipsis1' || pageNum === 'ellipsis2') {
|
||||
return (
|
||||
<div key={`ellipsis-${idx}`} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||
...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(Number(pageNum))}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
|
||||
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
|
||||
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 尾页按钮 */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.ceil(totalEvents / pageSize))}
|
||||
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${
|
||||
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Last</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L8.586 10 4.293 14.293a1 1 0 000 1.414zm6 0a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L15.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
|
||||
<div className="h-96">
|
||||
<TimeSeriesChart data={timeSeriesData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Device Analytics</h2>
|
||||
{deviceData && <DevicePieCharts data={deviceData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
|
||||
<GeoAnalytics
|
||||
data={geoData}
|
||||
onViewModeChange={(mode) => {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
groupBy: mode
|
||||
});
|
||||
|
||||
// 添加其他筛选参数
|
||||
if (selectedTeamIds.length > 0) {
|
||||
selectedTeamIds.forEach(id => params.append('teamId', id));
|
||||
}
|
||||
|
||||
if (selectedProjectIds.length > 0) {
|
||||
selectedProjectIds.forEach(id => params.append('projectId', id));
|
||||
}
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
selectedTagIds.forEach(id => params.append('tagId', id));
|
||||
}
|
||||
|
||||
// 刷新地理位置数据
|
||||
fetch(`/api/events/geo?${params}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
setGeoData(data.data);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to fetch geo data:', error));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
app/register/page.tsx
Normal file
195
app/register/page.tsx
Normal 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
484
app/utils/ipLocation.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
59
app/utils/supabase.ts
Normal file
59
app/utils/supabase.ts
Normal 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
151
docs/swagger-setup.md
Normal 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
|
||||
1523
lib/analytics.ts
1523
lib/analytics.ts
File diff suppressed because it is too large
Load Diff
287
lib/auth.tsx
Normal file
287
lib/auth.tsx
Normal 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;
|
||||
@@ -1,39 +1,135 @@
|
||||
import { createClient } from '@clickhouse/client';
|
||||
import type { EventsQueryParams } from './types';
|
||||
|
||||
// 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}')`);
|
||||
}
|
||||
|
||||
if (endTime) {
|
||||
filters.push(`event_time <= parseDateTimeBestEffort('${endTime}')`);
|
||||
}
|
||||
|
||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
}
|
||||
|
||||
// 构建通用过滤条件
|
||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||
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) {
|
||||
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}'`);
|
||||
}
|
||||
|
||||
// 添加团队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 ')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return results
|
||||
*/
|
||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
||||
// 构建分页条件
|
||||
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('执行查询:', 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('执行单一结果查询:', 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
65
lib/supabase.ts
Normal 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
184
lib/types.ts
Normal 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
6
lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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
454
package-lock.json
generated
@@ -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",
|
||||
|
||||
19
package.json
19
package.json
@@ -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,37 @@
|
||||
},
|
||||
"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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
2764
pnpm-lock.yaml
generated
2764
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
76
scripts/db/sql/clickhouse/create_shorturl_analytics.sql
Normal file
76
scripts/db/sql/clickhouse/create_shorturl_analytics.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
364
scripts/db/sync_mongo_to_events.ts
Normal file
364
scripts/db/sync_mongo_to_events.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// Sync data from MongoDB trace table to ClickHouse events table
|
||||
import { getVariable } from "npm:windmill-client@1";
|
||||
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
|
||||
|
||||
interface MongoConfig {
|
||||
host: string;
|
||||
port: string;
|
||||
db: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ClickHouseConfig {
|
||||
clickhouse_host: string;
|
||||
clickhouse_port: number;
|
||||
clickhouse_user: string;
|
||||
clickhouse_password: string;
|
||||
clickhouse_database: string;
|
||||
clickhouse_url: string;
|
||||
}
|
||||
|
||||
interface TraceRecord {
|
||||
_id: ObjectId;
|
||||
slugId: ObjectId;
|
||||
label: string | null;
|
||||
ip: string;
|
||||
type: number;
|
||||
platform: string;
|
||||
platformOS: string;
|
||||
browser: string;
|
||||
browserVersion: string;
|
||||
url: string;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
export async function main(
|
||||
batch_size = 1000,
|
||||
max_records = 9999999,
|
||||
timeout_minutes = 60,
|
||||
skip_clickhouse_check = false,
|
||||
force_insert = false
|
||||
) {
|
||||
const logWithTimestamp = (message: string) => {
|
||||
const now = new Date();
|
||||
console.log(`[${now.toISOString()}] ${message}`);
|
||||
};
|
||||
|
||||
logWithTimestamp("Starting sync from MongoDB to ClickHouse events table");
|
||||
logWithTimestamp(`Batch size: ${batch_size}, Max records: ${max_records}, Timeout: ${timeout_minutes} minutes`);
|
||||
|
||||
// Set timeout
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeout_minutes * 60 * 1000;
|
||||
|
||||
const checkTimeout = () => {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
console.log(`Execution time exceeded ${timeout_minutes} minutes, stopping`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get MongoDB and ClickHouse connection info
|
||||
let mongoConfig: MongoConfig;
|
||||
let clickhouseConfig: ClickHouseConfig;
|
||||
|
||||
try {
|
||||
const rawMongoConfig = await getVariable("f/shorturl_analytics/mongodb");
|
||||
mongoConfig = typeof rawMongoConfig === "string" ? JSON.parse(rawMongoConfig) : rawMongoConfig;
|
||||
|
||||
const rawClickhouseConfig = await getVariable("f/shorturl_analytics/clickhouse");
|
||||
clickhouseConfig = typeof rawClickhouseConfig === "string" ? JSON.parse(rawClickhouseConfig) : rawClickhouseConfig;
|
||||
} catch (error) {
|
||||
console.error("Failed to get config:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build MongoDB connection URL
|
||||
let mongoUrl = "mongodb://";
|
||||
if (mongoConfig.username && mongoConfig.password) {
|
||||
mongoUrl += `${mongoConfig.username}:${mongoConfig.password}@`;
|
||||
}
|
||||
mongoUrl += `${mongoConfig.host}:${mongoConfig.port}/${mongoConfig.db}`;
|
||||
|
||||
// Connect to MongoDB
|
||||
const client = new MongoClient();
|
||||
try {
|
||||
await client.connect(mongoUrl);
|
||||
console.log("MongoDB connected successfully");
|
||||
|
||||
const db = client.database(mongoConfig.db);
|
||||
const traceCollection = db.collection<TraceRecord>("trace");
|
||||
|
||||
// Build query conditions
|
||||
const query: Record<string, unknown> = {
|
||||
type: 1 // Only sync records with type 1
|
||||
};
|
||||
|
||||
// Count total records
|
||||
const totalRecords = await traceCollection.countDocuments(query);
|
||||
console.log(`Found ${totalRecords} records to sync`);
|
||||
|
||||
const recordsToProcess = Math.min(totalRecords, max_records);
|
||||
console.log(`Will process ${recordsToProcess} records`);
|
||||
|
||||
if (totalRecords === 0) {
|
||||
console.log("No records to sync, task completed");
|
||||
return {
|
||||
success: true,
|
||||
records_synced: 0,
|
||||
message: "No records to sync"
|
||||
};
|
||||
}
|
||||
|
||||
// Check ClickHouse connection
|
||||
const checkClickHouseConnection = async (): Promise<boolean> => {
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("Skipping ClickHouse connection check");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
logWithTimestamp("Testing ClickHouse connection...");
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`,
|
||||
},
|
||||
body: "SELECT 1",
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logWithTimestamp("ClickHouse connection test successful");
|
||||
return true;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
logWithTimestamp(`ClickHouse connection test failed: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logWithTimestamp(`ClickHouse connection test failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if records exist in ClickHouse
|
||||
const checkExistingRecords = async (records: TraceRecord[]): Promise<TraceRecord[]> => {
|
||||
if (records.length === 0) return [];
|
||||
|
||||
if (skip_clickhouse_check || force_insert) {
|
||||
logWithTimestamp(`Skipping ClickHouse duplicate check, will process all ${records.length} records`);
|
||||
return records;
|
||||
}
|
||||
|
||||
try {
|
||||
const recordIds = records.map(record => record._id.toString());
|
||||
|
||||
const query = `
|
||||
SELECT event_id
|
||||
FROM ${clickhouseConfig.clickhouse_database}.events
|
||||
WHERE event_attributes LIKE '%"mongo_id":"%'
|
||||
AND event_attributes LIKE ANY ('%${recordIds.join("%' OR '%")}%')
|
||||
FORMAT JSON
|
||||
`;
|
||||
|
||||
const response = await fetch(clickhouseConfig.clickhouse_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: query,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse query error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const existingIds = new Set(result.data.map((row: any) => {
|
||||
const matches = row.event_attributes.match(/"mongo_id":"([^"]+)"/);
|
||||
return matches ? matches[1] : null;
|
||||
}).filter(Boolean));
|
||||
|
||||
return records.filter(record => !existingIds.has(record._id.toString()));
|
||||
} catch (err) {
|
||||
logWithTimestamp(`Error checking existing records: ${(err as Error).message}`);
|
||||
return skip_clickhouse_check ? records : [];
|
||||
}
|
||||
};
|
||||
|
||||
// Process records function
|
||||
const processRecords = async (records: TraceRecord[]) => {
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const newRecords = await checkExistingRecords(records);
|
||||
if (newRecords.length === 0) {
|
||||
logWithTimestamp("All records already exist, skipping");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Prepare ClickHouse insert data
|
||||
const clickhouseData = newRecords.map(record => {
|
||||
const eventTime = new Date(record.createTime).toISOString();
|
||||
return {
|
||||
event_time: eventTime,
|
||||
event_type: "click",
|
||||
event_attributes: JSON.stringify({
|
||||
mongo_id: record._id.toString(),
|
||||
original_type: record.type
|
||||
}),
|
||||
|
||||
// Link information
|
||||
link_id: record.slugId.toString(),
|
||||
link_slug: "",
|
||||
link_label: record.label || "",
|
||||
link_title: "",
|
||||
link_original_url: record.url || "",
|
||||
link_attributes: "{}",
|
||||
link_created_at: eventTime,
|
||||
link_expires_at: null,
|
||||
link_tags: "[]",
|
||||
|
||||
// User information (empty as not available in trace)
|
||||
user_id: "",
|
||||
user_name: "",
|
||||
user_email: "",
|
||||
user_attributes: "{}",
|
||||
|
||||
// Team information (empty as not available in trace)
|
||||
team_id: "",
|
||||
team_name: "",
|
||||
team_attributes: "{}",
|
||||
|
||||
// Project information (empty as not available in trace)
|
||||
project_id: "",
|
||||
project_name: "",
|
||||
project_attributes: "{}",
|
||||
|
||||
// QR code information (empty as not available in trace)
|
||||
qr_code_id: "",
|
||||
qr_code_name: "",
|
||||
qr_code_attributes: "{}",
|
||||
|
||||
// Visitor information
|
||||
visitor_id: record._id.toString(),
|
||||
session_id: `${record._id.toString()}-${record.createTime}`,
|
||||
ip_address: record.ip || "",
|
||||
country: "",
|
||||
city: "",
|
||||
device_type: record.platform || "unknown",
|
||||
browser: record.browser || "",
|
||||
os: record.platformOS || "",
|
||||
user_agent: `${record.browser || ""} ${record.browserVersion || ""}`.trim(),
|
||||
|
||||
// Source information
|
||||
referrer: record.url || "",
|
||||
utm_source: "",
|
||||
utm_medium: "",
|
||||
utm_campaign: "",
|
||||
|
||||
// Interaction information
|
||||
time_spent_sec: 0,
|
||||
is_bounce: true,
|
||||
is_qr_scan: false,
|
||||
conversion_type: "visit",
|
||||
conversion_value: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Generate ClickHouse insert SQL
|
||||
const insertSQL = `
|
||||
INSERT INTO ${clickhouseConfig.clickhouse_database}.events
|
||||
FORMAT JSONEachRow
|
||||
${JSON.stringify(clickhouseData)}
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(clickhouseConfig.clickhouse_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: insertSQL,
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse insert error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
logWithTimestamp(`Successfully inserted ${newRecords.length} records to ClickHouse`);
|
||||
return newRecords.length;
|
||||
} catch (err) {
|
||||
logWithTimestamp(`Failed to insert data to ClickHouse: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Check ClickHouse connection before processing
|
||||
const clickhouseConnected = await checkClickHouseConnection();
|
||||
if (!clickhouseConnected && !skip_clickhouse_check) {
|
||||
throw new Error("ClickHouse connection failed, cannot continue sync");
|
||||
}
|
||||
|
||||
// Process records in batches
|
||||
let processedRecords = 0;
|
||||
let totalBatchRecords = 0;
|
||||
|
||||
for (let page = 0; processedRecords < recordsToProcess; page++) {
|
||||
if (checkTimeout()) {
|
||||
logWithTimestamp(`Processed ${processedRecords}/${recordsToProcess} records, stopping due to timeout`);
|
||||
break;
|
||||
}
|
||||
|
||||
logWithTimestamp(`Processing batch ${page+1}, completed ${processedRecords}/${recordsToProcess} records (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
|
||||
const records = await traceCollection.find(
|
||||
query,
|
||||
{
|
||||
allowDiskUse: true,
|
||||
sort: { createTime: 1 },
|
||||
skip: page * batch_size,
|
||||
limit: batch_size
|
||||
}
|
||||
).toArray();
|
||||
|
||||
if (records.length === 0) {
|
||||
logWithTimestamp("No more records found, sync complete");
|
||||
break;
|
||||
}
|
||||
|
||||
const batchSize = await processRecords(records);
|
||||
processedRecords += records.length;
|
||||
totalBatchRecords += batchSize;
|
||||
|
||||
logWithTimestamp(`Batch ${page+1} complete. Processed ${processedRecords}/${recordsToProcess} records, inserted ${totalBatchRecords} (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records_processed: processedRecords,
|
||||
records_synced: totalBatchRecords,
|
||||
message: "Data sync completed"
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error during sync:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed");
|
||||
}
|
||||
}
|
||||
@@ -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
72
test-supabase-login.mjs
Normal 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
1
types/react-simple-maps.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
146
types/supabase.ts
Normal file
146
types/supabase.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
409
windmill/sync_mongo_to_events.ts
Normal file
409
windmill/sync_mongo_to_events.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
// Sync data from MongoDB trace table to ClickHouse events table
|
||||
import { getVariable } from "npm:windmill-client@1";
|
||||
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
|
||||
|
||||
interface MongoConfig {
|
||||
host: string;
|
||||
port: string;
|
||||
db: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ClickHouseConfig {
|
||||
clickhouse_host: string;
|
||||
clickhouse_port: number;
|
||||
clickhouse_user: string;
|
||||
clickhouse_password: string;
|
||||
clickhouse_url: string;
|
||||
}
|
||||
|
||||
interface TraceRecord {
|
||||
_id: ObjectId;
|
||||
slugId: ObjectId;
|
||||
label: string | null;
|
||||
ip: string;
|
||||
type: number;
|
||||
platform: string;
|
||||
platformOS: string;
|
||||
browser: string;
|
||||
browserVersion: string;
|
||||
url: string;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
interface ShortRecord {
|
||||
_id: ObjectId;
|
||||
slug: string; // 短链接的slug部分
|
||||
origin: string; // 原始URL
|
||||
domain?: string; // 域名
|
||||
createTime: number; // 创建时间戳
|
||||
user?: string; // 创建用户
|
||||
title?: string; // 标题
|
||||
description?: string; // 描述
|
||||
tags?: string[]; // 标签
|
||||
active?: boolean; // 是否活跃
|
||||
expiresAt?: number; // 过期时间戳
|
||||
teamId?: string; // 团队ID
|
||||
projectId?: string; // 项目ID
|
||||
}
|
||||
|
||||
interface ClickHouseRow {
|
||||
event_id: string;
|
||||
event_attributes: string;
|
||||
}
|
||||
|
||||
export async function main(
|
||||
batch_size = 1000,
|
||||
max_records = 9999999,
|
||||
timeout_minutes = 60,
|
||||
skip_clickhouse_check = false,
|
||||
force_insert = false
|
||||
) {
|
||||
const logWithTimestamp = (message: string) => {
|
||||
const now = new Date();
|
||||
console.log(`[${now.toISOString()}] ${message}`);
|
||||
};
|
||||
|
||||
logWithTimestamp("Starting sync from MongoDB to ClickHouse events table");
|
||||
logWithTimestamp(`Batch size: ${batch_size}, Max records: ${max_records}, Timeout: ${timeout_minutes} minutes`);
|
||||
|
||||
// Set timeout
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeout_minutes * 60 * 1000;
|
||||
|
||||
const checkTimeout = () => {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
console.log(`Execution time exceeded ${timeout_minutes} minutes, stopping`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get MongoDB and ClickHouse connection info
|
||||
let mongoConfig: MongoConfig;
|
||||
let clickhouseConfig: ClickHouseConfig;
|
||||
|
||||
try {
|
||||
const rawMongoConfig = await getVariable("f/shorturl_analytics/mongodb");
|
||||
mongoConfig = typeof rawMongoConfig === "string" ? JSON.parse(rawMongoConfig) : rawMongoConfig;
|
||||
|
||||
const rawClickhouseConfig = await getVariable("f/shorturl_analytics/clickhouse");
|
||||
clickhouseConfig = typeof rawClickhouseConfig === "string" ? JSON.parse(rawClickhouseConfig) : rawClickhouseConfig;
|
||||
} catch (error) {
|
||||
console.error("Failed to get config:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build MongoDB connection URL
|
||||
let mongoUrl = "mongodb://";
|
||||
if (mongoConfig.username && mongoConfig.password) {
|
||||
mongoUrl += `${mongoConfig.username}:${mongoConfig.password}@`;
|
||||
}
|
||||
mongoUrl += `${mongoConfig.host}:${mongoConfig.port}/${mongoConfig.db}`;
|
||||
|
||||
// Connect to MongoDB
|
||||
const client = new MongoClient();
|
||||
try {
|
||||
await client.connect(mongoUrl);
|
||||
console.log("MongoDB connected successfully");
|
||||
|
||||
const db = client.database(mongoConfig.db);
|
||||
const traceCollection = db.collection<TraceRecord>("trace");
|
||||
const shortCollection = db.collection<ShortRecord>("short");
|
||||
|
||||
// Build query conditions
|
||||
const query: Record<string, unknown> = {
|
||||
type: 1 // Only sync records with type 1
|
||||
};
|
||||
|
||||
// Count total records
|
||||
const totalRecords = await traceCollection.countDocuments(query);
|
||||
console.log(`Found ${totalRecords} records to sync`);
|
||||
|
||||
const recordsToProcess = Math.min(totalRecords, max_records);
|
||||
console.log(`Will process ${recordsToProcess} records`);
|
||||
|
||||
if (totalRecords === 0) {
|
||||
console.log("No records to sync, task completed");
|
||||
return {
|
||||
success: true,
|
||||
records_synced: 0,
|
||||
message: "No records to sync"
|
||||
};
|
||||
}
|
||||
|
||||
// Check ClickHouse connection
|
||||
const checkClickHouseConnection = async (): Promise<boolean> => {
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("Skipping ClickHouse connection check");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
logWithTimestamp("Testing ClickHouse connection...");
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`,
|
||||
},
|
||||
body: "SELECT 1",
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logWithTimestamp("ClickHouse connection test successful");
|
||||
return true;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
logWithTimestamp(`ClickHouse connection test failed: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logWithTimestamp(`ClickHouse connection test failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if records exist in ClickHouse
|
||||
const checkExistingRecords = async (records: TraceRecord[]): Promise<TraceRecord[]> => {
|
||||
if (records.length === 0) return [];
|
||||
|
||||
if (skip_clickhouse_check || force_insert) {
|
||||
logWithTimestamp(`Skipping ClickHouse duplicate check, will process all ${records.length} records`);
|
||||
return records;
|
||||
}
|
||||
|
||||
try {
|
||||
const recordIds = records.map(record => record._id.toString());
|
||||
|
||||
const query = `
|
||||
SELECT event_id
|
||||
FROM shorturl_analytics.events
|
||||
WHERE event_attributes LIKE '%"mongo_id":"%'
|
||||
AND event_attributes LIKE ANY ('%${recordIds.join("%' OR '%")}%')
|
||||
FORMAT JSON
|
||||
`;
|
||||
|
||||
const response = await fetch(clickhouseConfig.clickhouse_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: query,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse query error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const existingIds = new Set(result.data.map((row: ClickHouseRow) => {
|
||||
const matches = row.event_attributes.match(/"mongo_id":"([^"]+)"/);
|
||||
return matches ? matches[1] : null;
|
||||
}).filter(Boolean));
|
||||
|
||||
return records.filter(record => !existingIds.has(record._id.toString()));
|
||||
} catch (err) {
|
||||
logWithTimestamp(`Error checking existing records: ${(err as Error).message}`);
|
||||
return skip_clickhouse_check ? records : [];
|
||||
}
|
||||
};
|
||||
|
||||
// Process records function
|
||||
const processRecords = async (records: TraceRecord[]) => {
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const newRecords = await checkExistingRecords(records);
|
||||
if (newRecords.length === 0) {
|
||||
logWithTimestamp("All records already exist, skipping");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get link information for all records
|
||||
const slugIds = newRecords.map(record => record.slugId);
|
||||
const shortLinks = await shortCollection.find({
|
||||
_id: { $in: slugIds }
|
||||
}).toArray();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const shortLinksMap = new Map(shortLinks.map(link => [link._id.toString(), link]));
|
||||
|
||||
// Prepare ClickHouse insert data
|
||||
const clickhouseData = newRecords.map(record => {
|
||||
const shortLink = shortLinksMap.get(record.slugId.toString());
|
||||
|
||||
// 将毫秒时间戳转换为 DateTime64(3) 格式
|
||||
const formatDateTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toISOString().replace('T', ' ').replace('Z', '');
|
||||
};
|
||||
|
||||
return {
|
||||
// Event base information
|
||||
event_id: record._id.toString(),
|
||||
event_time: formatDateTime(record.createTime),
|
||||
event_type: "click",
|
||||
event_attributes: JSON.stringify({
|
||||
original_type: record.type
|
||||
}),
|
||||
|
||||
// Link information from short collection
|
||||
link_id: record.slugId.toString(),
|
||||
link_slug: shortLink?.slug || "",
|
||||
link_label: record.label || "",
|
||||
link_title: "",
|
||||
link_original_url: shortLink?.origin || "",
|
||||
link_attributes: JSON.stringify({
|
||||
domain: shortLink?.domain || null
|
||||
}),
|
||||
link_created_at: shortLink?.createTime ? formatDateTime(shortLink.createTime) : formatDateTime(record.createTime),
|
||||
link_expires_at: shortLink?.expiresAt ? formatDateTime(shortLink.expiresAt) : null,
|
||||
link_tags: "[]", // Empty array as default
|
||||
|
||||
// User information
|
||||
user_id: shortLink?.user || "",
|
||||
user_name: "",
|
||||
user_email: "",
|
||||
user_attributes: "{}",
|
||||
|
||||
// Team information
|
||||
team_id: shortLink?.teamId || "",
|
||||
team_name: "",
|
||||
team_attributes: "{}",
|
||||
|
||||
// Project information
|
||||
project_id: shortLink?.projectId || "",
|
||||
project_name: "",
|
||||
project_attributes: "{}",
|
||||
|
||||
// QR code information
|
||||
qr_code_id: "",
|
||||
qr_code_name: "",
|
||||
qr_code_attributes: "{}",
|
||||
|
||||
// Visitor information
|
||||
visitor_id: "", // Empty string as default
|
||||
session_id: `${record.slugId.toString()}-${record.createTime}`,
|
||||
ip_address: record.ip || "",
|
||||
country: "",
|
||||
city: "",
|
||||
device_type: record.platform || "",
|
||||
browser: record.browser || "",
|
||||
os: record.platformOS || "",
|
||||
user_agent: `${record.browser || ""} ${record.browserVersion || ""}`.trim(),
|
||||
|
||||
// Source information
|
||||
referrer: record.url || "",
|
||||
utm_source: "",
|
||||
utm_medium: "",
|
||||
utm_campaign: "",
|
||||
|
||||
// Interaction information
|
||||
time_spent_sec: 0,
|
||||
is_bounce: true,
|
||||
is_qr_scan: false,
|
||||
conversion_type: "visit",
|
||||
conversion_value: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Generate ClickHouse insert SQL
|
||||
const rows = clickhouseData.map(row => {
|
||||
// 只需要处理JSON字符串的转义
|
||||
const formattedRow = {
|
||||
...row,
|
||||
event_attributes: row.event_attributes.replace(/\\/g, '\\\\'),
|
||||
link_attributes: row.link_attributes.replace(/\\/g, '\\\\')
|
||||
};
|
||||
return JSON.stringify(formattedRow);
|
||||
}).join('\n');
|
||||
|
||||
const insertSQL = `INSERT INTO shorturl_analytics.events FORMAT JSONEachRow\n${rows}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(clickhouseConfig.clickhouse_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: insertSQL,
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse insert error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
logWithTimestamp(`Successfully inserted ${newRecords.length} records to ClickHouse`);
|
||||
return newRecords.length;
|
||||
} catch (err) {
|
||||
logWithTimestamp(`Failed to insert data to ClickHouse: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Check ClickHouse connection before processing
|
||||
const clickhouseConnected = await checkClickHouseConnection();
|
||||
if (!clickhouseConnected && !skip_clickhouse_check) {
|
||||
throw new Error("ClickHouse connection failed, cannot continue sync");
|
||||
}
|
||||
|
||||
// Process records in batches
|
||||
let processedRecords = 0;
|
||||
let totalBatchRecords = 0;
|
||||
|
||||
for (let page = 0; processedRecords < recordsToProcess; page++) {
|
||||
if (checkTimeout()) {
|
||||
logWithTimestamp(`Processed ${processedRecords}/${recordsToProcess} records, stopping due to timeout`);
|
||||
break;
|
||||
}
|
||||
|
||||
logWithTimestamp(`Processing batch ${page+1}, completed ${processedRecords}/${recordsToProcess} records (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
|
||||
const records = await traceCollection.find(
|
||||
query,
|
||||
{
|
||||
allowDiskUse: true,
|
||||
sort: { createTime: 1 },
|
||||
skip: page * batch_size,
|
||||
limit: batch_size
|
||||
}
|
||||
).toArray();
|
||||
|
||||
if (records.length === 0) {
|
||||
logWithTimestamp("No more records found, sync complete");
|
||||
break;
|
||||
}
|
||||
|
||||
const batchSize = await processRecords(records);
|
||||
processedRecords += records.length;
|
||||
totalBatchRecords += batchSize;
|
||||
|
||||
logWithTimestamp(`Batch ${page+1} complete. Processed ${processedRecords}/${recordsToProcess} records, inserted ${totalBatchRecords} (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records_processed: processedRecords,
|
||||
records_synced: totalBatchRecords,
|
||||
message: "Data sync completed"
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error during sync:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed");
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
// 从MongoDB的trace表同步数据到ClickHouse的link_events表
|
||||
import { getVariable, setVariable } from "npm:windmill-client@1";
|
||||
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
|
||||
|
||||
interface MongoConfig {
|
||||
host: string;
|
||||
port: string;
|
||||
db: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ClickHouseConfig {
|
||||
clickhouse_host: string;
|
||||
clickhouse_port: number;
|
||||
clickhouse_user: string;
|
||||
clickhouse_password: string;
|
||||
clickhouse_database: string;
|
||||
clickhouse_url: string;
|
||||
}
|
||||
|
||||
interface TraceRecord {
|
||||
_id: ObjectId;
|
||||
slugId: ObjectId;
|
||||
label: string | null;
|
||||
ip: string;
|
||||
type: number;
|
||||
platform: string;
|
||||
platformOS: string;
|
||||
browser: string;
|
||||
browserVersion: string;
|
||||
url: string;
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
last_sync_time: number;
|
||||
records_synced: number;
|
||||
last_sync_id?: string;
|
||||
}
|
||||
|
||||
export async function main(
|
||||
batch_size = 1000,
|
||||
max_records = 9999999,
|
||||
timeout_minutes = 60,
|
||||
skip_clickhouse_check = false,
|
||||
force_insert = false
|
||||
) {
|
||||
const logWithTimestamp = (message: string) => {
|
||||
const now = new Date();
|
||||
console.log(`[${now.toISOString()}] ${message}`);
|
||||
};
|
||||
|
||||
logWithTimestamp("开始执行MongoDB到ClickHouse的同步任务");
|
||||
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`);
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式,不会检查记录是否已存在");
|
||||
}
|
||||
if (force_insert) {
|
||||
logWithTimestamp("⚠️ 警告: 已启用强制插入模式,将尝试插入所有记录");
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeout_minutes * 60 * 1000;
|
||||
|
||||
// 检查是否超时
|
||||
const checkTimeout = () => {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
console.log(`运行时间超过${timeout_minutes}分钟,暂停执行`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 获取MongoDB和ClickHouse的连接信息
|
||||
let mongoConfig: MongoConfig;
|
||||
let clickhouseConfig: ClickHouseConfig;
|
||||
|
||||
try {
|
||||
const rawMongoConfig = await getVariable("f/shorturl_analytics/mongodb");
|
||||
console.log("原始MongoDB配置:", JSON.stringify(rawMongoConfig));
|
||||
|
||||
// 尝试解析配置,如果是字符串形式
|
||||
if (typeof rawMongoConfig === "string") {
|
||||
try {
|
||||
mongoConfig = JSON.parse(rawMongoConfig);
|
||||
} catch (e) {
|
||||
console.error("MongoDB配置解析失败:", e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
mongoConfig = rawMongoConfig as MongoConfig;
|
||||
}
|
||||
|
||||
const rawClickhouseConfig = await getVariable("f/shorturl_analytics/clickhouse");
|
||||
console.log("原始ClickHouse配置:", JSON.stringify(rawClickhouseConfig));
|
||||
|
||||
// 尝试解析配置,如果是字符串形式
|
||||
if (typeof rawClickhouseConfig === "string") {
|
||||
try {
|
||||
clickhouseConfig = JSON.parse(rawClickhouseConfig);
|
||||
} catch (e) {
|
||||
console.error("ClickHouse配置解析失败:", e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
clickhouseConfig = rawClickhouseConfig as ClickHouseConfig;
|
||||
}
|
||||
|
||||
console.log("MongoDB配置解析为:", JSON.stringify(mongoConfig));
|
||||
console.log("ClickHouse配置解析为:", JSON.stringify(clickhouseConfig));
|
||||
} catch (error) {
|
||||
console.error("获取配置失败:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 构建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(/:[^:]*@/, ":****@")}`);
|
||||
|
||||
// 连接MongoDB
|
||||
const client = new MongoClient();
|
||||
try {
|
||||
await client.connect(mongoUrl);
|
||||
console.log("MongoDB连接成功");
|
||||
|
||||
const db = client.database(mongoConfig.db);
|
||||
const traceCollection = db.collection<TraceRecord>("trace");
|
||||
|
||||
// 构建查询条件,获取所有记录
|
||||
const query: Record<string, unknown> = {
|
||||
type: 1 // 只同步type为1的记录
|
||||
};
|
||||
|
||||
// 计算总记录数
|
||||
const totalRecords = await traceCollection.countDocuments(query);
|
||||
console.log(`找到 ${totalRecords} 条记录需要同步`);
|
||||
|
||||
// 限制此次处理的记录数量
|
||||
const recordsToProcess = Math.min(totalRecords, max_records);
|
||||
console.log(`本次将处理 ${recordsToProcess} 条记录`);
|
||||
|
||||
if (totalRecords === 0) {
|
||||
console.log("没有记录需要同步,任务完成");
|
||||
return {
|
||||
success: true,
|
||||
records_synced: 0,
|
||||
message: "没有记录需要同步"
|
||||
};
|
||||
}
|
||||
|
||||
// 检查ClickHouse连接状态
|
||||
const checkClickHouseConnection = async (): Promise<boolean> => {
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("已启用跳过ClickHouse检查,不测试连接");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
logWithTimestamp("测试ClickHouse连接...");
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`,
|
||||
},
|
||||
body: "SELECT 1",
|
||||
// 设置5秒超时
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logWithTimestamp("ClickHouse连接测试成功");
|
||||
return true;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
logWithTimestamp(`ClickHouse连接测试失败: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`ClickHouse连接测试失败: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查记录是否已经存在于ClickHouse中
|
||||
const checkExistingRecords = async (records: TraceRecord[]): Promise<TraceRecord[]> => {
|
||||
if (records.length === 0) return [];
|
||||
|
||||
// 如果跳过ClickHouse检查或强制插入,则直接返回所有记录
|
||||
if (skip_clickhouse_check || force_insert) {
|
||||
logWithTimestamp(`已跳过ClickHouse重复检查,准备处理所有 ${records.length} 条记录`);
|
||||
return records;
|
||||
}
|
||||
|
||||
logWithTimestamp(`正在检查 ${records.length} 条记录是否已存在于ClickHouse中...`);
|
||||
|
||||
try {
|
||||
// 提取所有记录的ID
|
||||
const recordIds = records.map(record => record.slugId.toString()); // 使用slugId作为link_id查询
|
||||
logWithTimestamp(`待检查的记录ID: ${recordIds.join(', ')}`);
|
||||
|
||||
// 构建查询SQL,检查记录是否已存在,确保添加FORMAT JSON来获取正确的JSON格式响应
|
||||
const query = `
|
||||
SELECT link_id
|
||||
FROM ${clickhouseConfig.clickhouse_database}.link_events
|
||||
WHERE link_id IN ('${recordIds.join("','")}')
|
||||
FORMAT JSON
|
||||
`;
|
||||
|
||||
logWithTimestamp(`执行ClickHouse查询: ${query.replace(/\n\s*/g, ' ')}`);
|
||||
|
||||
// 发送请求到ClickHouse,添加10秒超时
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: query,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse查询错误: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
// 获取响应文本以便记录
|
||||
const responseText = await response.text();
|
||||
logWithTimestamp(`ClickHouse查询响应: ${responseText.slice(0, 200)}${responseText.length > 200 ? '...' : ''}`);
|
||||
|
||||
if (!responseText.trim()) {
|
||||
logWithTimestamp("ClickHouse返回空响应,假定没有记录存在");
|
||||
return records; // 如果响应为空,假设没有记录
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch (err) {
|
||||
logWithTimestamp(`ClickHouse响应不是有效的JSON: ${responseText}`);
|
||||
throw new Error(`解析ClickHouse响应失败: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 确保result有正确的结构
|
||||
if (!result.data) {
|
||||
logWithTimestamp(`ClickHouse响应缺少data字段: ${JSON.stringify(result)}`);
|
||||
return records; // 如果没有data字段,假设没有记录
|
||||
}
|
||||
|
||||
// 提取已存在的记录ID
|
||||
const existingIds = new Set(result.data.map((row: { link_id: string }) => row.link_id));
|
||||
|
||||
logWithTimestamp(`检测到 ${existingIds.size} 条记录已存在于ClickHouse中`);
|
||||
if (existingIds.size > 0) {
|
||||
logWithTimestamp(`已存在的记录ID: ${Array.from(existingIds).join(', ')}`);
|
||||
}
|
||||
|
||||
// 过滤出不存在的记录
|
||||
const newRecords = records.filter(record => !existingIds.has(record.slugId.toString())); // 使用slugId匹配link_id
|
||||
logWithTimestamp(`过滤后剩余 ${newRecords.length} 条新记录需要插入`);
|
||||
|
||||
return newRecords;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`ClickHouse查询出错: ${error.message}`);
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("已启用跳过ClickHouse检查,将继续处理所有记录");
|
||||
return records;
|
||||
} else {
|
||||
throw error; // 如果没有启用跳过检查,则抛出错误
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 在处理记录前先检查ClickHouse连接
|
||||
const clickhouseConnected = await checkClickHouseConnection();
|
||||
if (!clickhouseConnected && !skip_clickhouse_check) {
|
||||
logWithTimestamp("⚠️ ClickHouse连接测试失败,请启用skip_clickhouse_check=true参数来跳过连接检查");
|
||||
throw new Error("ClickHouse连接失败,无法继续同步");
|
||||
}
|
||||
|
||||
// 处理记录的函数
|
||||
const processRecords = async (records: TraceRecord[]) => {
|
||||
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_clickhouse_check && !force_insert) {
|
||||
throw error;
|
||||
}
|
||||
// 如果跳过检查或强制插入,则使用所有记录
|
||||
logWithTimestamp("将使用所有记录进行处理");
|
||||
newRecords = records;
|
||||
}
|
||||
|
||||
if (newRecords.length === 0) {
|
||||
logWithTimestamp("所有记录都已存在,跳过处理");
|
||||
return 0;
|
||||
}
|
||||
|
||||
logWithTimestamp(`准备处理 ${newRecords.length} 条新记录...`);
|
||||
|
||||
// 准备ClickHouse插入数据
|
||||
const clickhouseData = newRecords.map(record => {
|
||||
// 转换MongoDB记录为ClickHouse格式,匹配ClickHouse表结构
|
||||
return {
|
||||
// UUID将由ClickHouse自动生成 (event_id)
|
||||
link_id: record.slugId.toString(),
|
||||
channel_id: record.label || "",
|
||||
visitor_id: record._id.toString(), // 使用MongoDB ID作为访客ID
|
||||
session_id: record._id.toString() + "-" + record.createTime, // 创建一个唯一会话ID
|
||||
event_type: record.type <= 4 ? record.type : 1, // 确保event_type在枚举范围内
|
||||
ip_address: record.ip,
|
||||
country: "", // 这些字段在MongoDB中不存在,使用默认值
|
||||
city: "",
|
||||
referrer: record.url || "",
|
||||
utm_source: "",
|
||||
utm_medium: "",
|
||||
utm_campaign: "",
|
||||
user_agent: record.browser + " " + record.browserVersion,
|
||||
device_type: record.platform || "unknown",
|
||||
browser: record.browser || "",
|
||||
os: record.platformOS || "",
|
||||
time_spent_sec: 0,
|
||||
is_bounce: true,
|
||||
is_qr_scan: false,
|
||||
qr_code_id: "",
|
||||
conversion_type: 1, // 默认为'visit'
|
||||
conversion_value: 0,
|
||||
custom_data: `{"mongo_id":"${record._id.toString()}"}`
|
||||
};
|
||||
});
|
||||
|
||||
// 生成ClickHouse插入SQL
|
||||
const insertSQL = `
|
||||
INSERT INTO ${clickhouseConfig.clickhouse_database}.link_events
|
||||
(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)
|
||||
VALUES ${clickhouseData.map(record => {
|
||||
// 确保所有字符串值都是字符串类型,并安全处理替换
|
||||
const safeReplace = (val: any): string => {
|
||||
// 确保值是字符串,如果是null或undefined则使用空字符串
|
||||
const str = val === null || val === undefined ? "" : String(val);
|
||||
// 安全替换单引号
|
||||
return str.replace(/'/g, "''");
|
||||
};
|
||||
|
||||
return `('${record.link_id}', '${safeReplace(record.channel_id)}', '${record.visitor_id}', '${record.session_id}',
|
||||
${record.event_type}, '${safeReplace(record.ip_address)}', '', '',
|
||||
'${safeReplace(record.referrer)}', '', '', '', '${safeReplace(record.user_agent)}', '${safeReplace(record.device_type)}',
|
||||
'${safeReplace(record.browser)}', '${safeReplace(record.os)}',
|
||||
0, true, false, '', 1, 0, '${safeReplace(record.custom_data)}')`;
|
||||
}).join(", ")}
|
||||
`;
|
||||
|
||||
if (insertSQL.length === 0) {
|
||||
console.log("没有新记录需要插入");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 发送请求到ClickHouse,添加20秒超时
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
try {
|
||||
logWithTimestamp("发送插入请求到ClickHouse...");
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: insertSQL,
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse插入错误: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
logWithTimestamp(`成功插入 ${newRecords.length} 条记录到ClickHouse`);
|
||||
return newRecords.length;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`向ClickHouse插入数据失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 批量处理记录
|
||||
let processedRecords = 0;
|
||||
let totalBatchRecords = 0;
|
||||
|
||||
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 traceCollection.find(
|
||||
query,
|
||||
{
|
||||
allowDiskUse: true,
|
||||
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}, 时间=${new Date(records[0].createTime).toISOString()}`);
|
||||
if (records.length > 1) {
|
||||
logWithTimestamp(`批次 ${page+1} 最后一条记录: ID=${records[records.length-1]._id}, 时间=${new Date(records[records.length-1].createTime).toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = await processRecords(records);
|
||||
processedRecords += records.length;
|
||||
totalBatchRecords += batchSize;
|
||||
|
||||
logWithTimestamp(`第 ${page+1} 批次处理完成。已处理 ${processedRecords}/${recordsToProcess} 条记录,实际插入 ${totalBatchRecords} 条 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records_processed: processedRecords,
|
||||
records_synced: totalBatchRecords,
|
||||
message: "数据同步完成"
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("同步过程中发生错误:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
};
|
||||
} finally {
|
||||
// 关闭MongoDB连接
|
||||
await client.close();
|
||||
console.log("MongoDB连接已关闭");
|
||||
}
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
// 从MongoDB的short表同步数据到ClickHouse的links表
|
||||
import { getVariable, setVariable } from "npm:windmill-client@1";
|
||||
import { MongoClient, ObjectId } from "https://deno.land/x/mongo@v0.32.0/mod.ts";
|
||||
|
||||
interface MongoConfig {
|
||||
host: string;
|
||||
port: string;
|
||||
db: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ClickHouseConfig {
|
||||
clickhouse_host: string;
|
||||
clickhouse_port: number;
|
||||
clickhouse_user: string;
|
||||
clickhouse_password: string;
|
||||
clickhouse_database: string;
|
||||
clickhouse_url: string;
|
||||
}
|
||||
|
||||
interface ShortRecord {
|
||||
_id: ObjectId;
|
||||
slug: string; // 短链接的slug部分
|
||||
origin: string; // 原始URL
|
||||
domain?: string; // 域名
|
||||
createTime: number; // 创建时间戳
|
||||
user?: string; // 创建用户
|
||||
title?: string; // 标题
|
||||
description?: string; // 描述
|
||||
tags?: string[]; // 标签
|
||||
active?: boolean; // 是否活跃
|
||||
expiresAt?: number; // 过期时间戳
|
||||
teamId?: string; // 团队ID
|
||||
projectId?: string; // 项目ID
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
last_sync_time: number;
|
||||
records_synced: number;
|
||||
last_sync_id?: string;
|
||||
}
|
||||
|
||||
export async function main(
|
||||
batch_size = 100,
|
||||
initial_sync = false,
|
||||
max_records = 999999,
|
||||
timeout_minutes = 30,
|
||||
skip_clickhouse_check = false,
|
||||
force_insert = false
|
||||
) {
|
||||
const logWithTimestamp = (message: string) => {
|
||||
const now = new Date();
|
||||
console.log(`[${now.toISOString()}] ${message}`);
|
||||
};
|
||||
|
||||
logWithTimestamp("开始执行MongoDB到ClickHouse的短链接同步任务...");
|
||||
logWithTimestamp(`批处理大小: ${batch_size}, 最大记录数: ${max_records}, 超时时间: ${timeout_minutes}分钟`);
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("⚠️ 警告: 已启用跳过ClickHouse检查模式,不会检查记录是否已存在");
|
||||
}
|
||||
if (force_insert) {
|
||||
logWithTimestamp("⚠️ 警告: 已启用强制插入模式,将尝试插入所有记录");
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeout_minutes * 60 * 1000;
|
||||
|
||||
// 检查是否超时
|
||||
const checkTimeout = () => {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
console.log(`运行时间超过${timeout_minutes}分钟,暂停执行`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 获取MongoDB和ClickHouse的连接信息
|
||||
let mongoConfig: MongoConfig;
|
||||
let clickhouseConfig: ClickHouseConfig;
|
||||
|
||||
try {
|
||||
const rawMongoConfig = await getVariable("f/shorturl_analytics/mongodb");
|
||||
console.log("原始MongoDB配置:", typeof rawMongoConfig === "string" ? rawMongoConfig : JSON.stringify(rawMongoConfig));
|
||||
|
||||
// 尝试解析配置,如果是字符串形式
|
||||
if (typeof rawMongoConfig === "string") {
|
||||
try {
|
||||
mongoConfig = JSON.parse(rawMongoConfig);
|
||||
} catch (e) {
|
||||
console.error("MongoDB配置解析失败:", e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
mongoConfig = rawMongoConfig as MongoConfig;
|
||||
}
|
||||
|
||||
const rawClickhouseConfig = await getVariable("f/shorturl_analytics/clickhouse");
|
||||
console.log("原始ClickHouse配置:", typeof rawClickhouseConfig === "string" ? rawClickhouseConfig : JSON.stringify(rawClickhouseConfig));
|
||||
|
||||
// 尝试解析配置,如果是字符串形式
|
||||
if (typeof rawClickhouseConfig === "string") {
|
||||
try {
|
||||
clickhouseConfig = JSON.parse(rawClickhouseConfig);
|
||||
} catch (e) {
|
||||
console.error("ClickHouse配置解析失败:", e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
clickhouseConfig = rawClickhouseConfig as ClickHouseConfig;
|
||||
}
|
||||
|
||||
console.log("MongoDB配置解析为:", JSON.stringify(mongoConfig));
|
||||
console.log("ClickHouse配置解析为:", JSON.stringify(clickhouseConfig));
|
||||
} catch (error) {
|
||||
console.error("获取配置失败:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 构建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(/:[^:]*@/, ":****@")}`);
|
||||
|
||||
// 获取上次同步的状态
|
||||
let syncState: SyncState;
|
||||
try {
|
||||
const rawSyncState = await getVariable<string>("f/shorturl_analytics/clickhouse/shorturl_links_sync_state");
|
||||
try {
|
||||
syncState = JSON.parse(rawSyncState);
|
||||
console.log(`获取同步状态成功: 上次同步时间 ${new Date(syncState.last_sync_time).toISOString()}`);
|
||||
} catch (parseError) {
|
||||
console.error("解析同步状态失败:", parseError);
|
||||
throw parseError;
|
||||
}
|
||||
} catch (_unused_error) {
|
||||
console.log("未找到同步状态,创建初始同步状态");
|
||||
syncState = {
|
||||
last_sync_time: 0,
|
||||
records_synced: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果强制从头开始同步
|
||||
if (initial_sync) {
|
||||
console.log("强制从头开始同步");
|
||||
syncState = {
|
||||
last_sync_time: 0,
|
||||
records_synced: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 连接MongoDB
|
||||
const client = new MongoClient();
|
||||
try {
|
||||
await client.connect(mongoUrl);
|
||||
console.log("MongoDB连接成功");
|
||||
|
||||
const db = client.database(mongoConfig.db);
|
||||
const shortCollection = db.collection<ShortRecord>("short");
|
||||
|
||||
// 构建查询条件,只查询新的记录
|
||||
const query: Record<string, unknown> = {};
|
||||
|
||||
if (syncState.last_sync_time > 0) {
|
||||
query.createTime = { $gt: syncState.last_sync_time };
|
||||
}
|
||||
|
||||
if (syncState.last_sync_id) {
|
||||
// 如果有上次同步的ID,则从该ID之后开始查询
|
||||
query._id = { $gt: new ObjectId(syncState.last_sync_id) };
|
||||
}
|
||||
|
||||
// 计算总记录数
|
||||
const totalRecords = await shortCollection.countDocuments(query);
|
||||
console.log(`找到 ${totalRecords} 条新短链接记录需要同步`);
|
||||
|
||||
// 限制此次处理的记录数量
|
||||
const recordsToProcess = Math.min(totalRecords, max_records);
|
||||
console.log(`本次将处理 ${recordsToProcess} 条记录`);
|
||||
|
||||
if (totalRecords === 0) {
|
||||
console.log("没有新记录需要同步,任务完成");
|
||||
return {
|
||||
success: true,
|
||||
records_synced: 0,
|
||||
total_synced: syncState.records_synced,
|
||||
message: "没有新记录需要同步"
|
||||
};
|
||||
}
|
||||
|
||||
// 分批处理记录
|
||||
let processedRecords = 0;
|
||||
let lastId: string | undefined;
|
||||
let lastCreateTime = syncState.last_sync_time;
|
||||
let totalBatchRecords = 0;
|
||||
|
||||
// 检查ClickHouse连接状态
|
||||
const checkClickHouseConnection = async (): Promise<boolean> => {
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("已启用跳过ClickHouse检查,不测试连接");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
logWithTimestamp("测试ClickHouse连接...");
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`,
|
||||
},
|
||||
body: "SELECT 1 FORMAT JSON",
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logWithTimestamp("ClickHouse连接测试成功");
|
||||
return true;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
logWithTimestamp(`ClickHouse连接测试失败: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`ClickHouse连接测试失败: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查记录是否已经存在于ClickHouse中
|
||||
const checkExistingRecords = async (records: ShortRecord[]): Promise<ShortRecord[]> => {
|
||||
if (records.length === 0) return [];
|
||||
|
||||
// 如果跳过ClickHouse检查或强制插入,则直接返回所有记录
|
||||
if (skip_clickhouse_check || force_insert) {
|
||||
logWithTimestamp(`已跳过ClickHouse重复检查,准备处理所有 ${records.length} 条记录`);
|
||||
return records;
|
||||
}
|
||||
|
||||
logWithTimestamp(`正在检查 ${records.length} 条短链接记录是否已存在于ClickHouse中...`);
|
||||
|
||||
try {
|
||||
// 提取所有记录的ID
|
||||
const recordIds = records.map(record => record._id.toString());
|
||||
logWithTimestamp(`待检查的短链接ID: ${recordIds.join(', ')}`);
|
||||
|
||||
// 构建查询SQL,检查记录是否已存在
|
||||
const query = `
|
||||
SELECT link_id
|
||||
FROM ${clickhouseConfig.clickhouse_database}.links
|
||||
WHERE link_id IN ('${recordIds.join("','")}')
|
||||
FORMAT JSON
|
||||
`;
|
||||
|
||||
logWithTimestamp(`执行ClickHouse查询: ${query.replace(/\n\s*/g, ' ')}`);
|
||||
|
||||
// 发送请求到ClickHouse
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: query,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse查询错误: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
// 获取响应文本
|
||||
const responseText = await response.text();
|
||||
logWithTimestamp(`ClickHouse查询响应: ${responseText.slice(0, 200)}${responseText.length > 200 ? '...' : ''}`);
|
||||
|
||||
if (!responseText.trim()) {
|
||||
logWithTimestamp("ClickHouse返回空响应,假定没有记录存在");
|
||||
return records;
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch (err) {
|
||||
logWithTimestamp(`ClickHouse响应不是有效的JSON: ${responseText}`);
|
||||
throw new Error(`解析ClickHouse响应失败: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 确保result有正确的结构
|
||||
if (!result.data) {
|
||||
logWithTimestamp(`ClickHouse响应缺少data字段: ${JSON.stringify(result)}`);
|
||||
return records;
|
||||
}
|
||||
|
||||
// 提取已存在的记录ID
|
||||
const existingIds = new Set(result.data.map((row: { link_id: string }) => row.link_id));
|
||||
|
||||
logWithTimestamp(`检测到 ${existingIds.size} 条记录已存在于ClickHouse中`);
|
||||
if (existingIds.size > 0) {
|
||||
logWithTimestamp(`已存在的记录ID: ${Array.from(existingIds).join(', ')}`);
|
||||
}
|
||||
|
||||
// 过滤出不存在的记录
|
||||
const newRecords = records.filter(record => !existingIds.has(record._id.toString()));
|
||||
logWithTimestamp(`过滤后剩余 ${newRecords.length} 条新记录需要插入`);
|
||||
|
||||
return newRecords;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`ClickHouse查询出错: ${error.message}`);
|
||||
if (skip_clickhouse_check) {
|
||||
logWithTimestamp("已启用跳过ClickHouse检查,将继续处理所有记录");
|
||||
return records;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 在处理记录前先检查ClickHouse连接
|
||||
const clickhouseConnected = await checkClickHouseConnection();
|
||||
if (!clickhouseConnected && !skip_clickhouse_check) {
|
||||
logWithTimestamp("⚠️ ClickHouse连接测试失败,请启用skip_clickhouse_check=true参数来跳过连接检查");
|
||||
throw new Error("ClickHouse连接失败,无法继续同步");
|
||||
}
|
||||
|
||||
// 处理记录的函数
|
||||
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_clickhouse_check && !force_insert) {
|
||||
throw error;
|
||||
}
|
||||
// 如果跳过检查或强制插入,则使用所有记录
|
||||
logWithTimestamp("将使用所有记录进行处理");
|
||||
newRecords = records;
|
||||
}
|
||||
|
||||
if (newRecords.length === 0) {
|
||||
logWithTimestamp("所有记录都已存在,跳过处理");
|
||||
// 更新同步状态,即使没有新增记录
|
||||
const lastRecord = records[records.length - 1];
|
||||
lastId = lastRecord._id.toString();
|
||||
lastCreateTime = lastRecord.createTime;
|
||||
return 0;
|
||||
}
|
||||
|
||||
logWithTimestamp(`准备处理 ${newRecords.length} 条新短链接记录...`);
|
||||
|
||||
// 准备ClickHouse插入数据
|
||||
const clickhouseData = newRecords.map(record => {
|
||||
// 转换MongoDB记录为ClickHouse格式,匹配ClickHouse表结构
|
||||
// 处理日期时间,移除ISO格式中的Z以使ClickHouse正确解析
|
||||
const createdAtStr = new Date(record.createTime).toISOString().replace('Z', '');
|
||||
const expiresAtStr = record.expiresAt ? new Date(record.expiresAt).toISOString().replace('Z', '') : null;
|
||||
|
||||
return {
|
||||
link_id: record._id.toString(), // 使用MongoDB的_id作为link_id
|
||||
original_url: record.origin || "",
|
||||
created_at: createdAtStr,
|
||||
created_by: record.user || "unknown",
|
||||
title: record.slug, // 使用slug作为title
|
||||
description: record.description || "",
|
||||
tags: record.tags || [],
|
||||
is_active: record.active !== undefined ? record.active : true,
|
||||
expires_at: expiresAtStr,
|
||||
team_id: record.teamId || "",
|
||||
project_id: record.projectId || ""
|
||||
};
|
||||
});
|
||||
|
||||
// 更新同步状态(使用原始records的最后一条,以确保进度正确)
|
||||
const lastRecord = records[records.length - 1];
|
||||
lastId = lastRecord._id.toString();
|
||||
lastCreateTime = lastRecord.createTime;
|
||||
logWithTimestamp(`更新同步位置到: ID=${lastId}, 时间=${new Date(lastCreateTime).toISOString()}`);
|
||||
|
||||
// 生成ClickHouse插入SQL
|
||||
// 注意:Array类型需要特殊处理,这里将tags作为JSON字符串处理
|
||||
const insertSQL = `
|
||||
INSERT INTO ${clickhouseConfig.clickhouse_database}.links
|
||||
(link_id, original_url, created_at, created_by, title, description, tags, is_active, expires_at, team_id, project_id)
|
||||
VALUES
|
||||
${clickhouseData.map(record => {
|
||||
// 处理tags数组
|
||||
const tagsStr = JSON.stringify(record.tags || []);
|
||||
|
||||
// 处理expires_at可能为null的情况
|
||||
const expiresAt = record.expires_at ? `'${record.expires_at}'` : "NULL";
|
||||
|
||||
// 确保所有字段在使用replace前都有默认值
|
||||
const safeOriginalUrl = (record.original_url || "").replace(/'/g, "''");
|
||||
const safeCreatedBy = (record.created_by || "unknown").replace(/'/g, "''");
|
||||
const safeTitle = (record.title || "无标题").replace(/'/g, "''");
|
||||
const safeDescription = (record.description || "").replace(/'/g, "''");
|
||||
const safeTeamId = record.team_id || "";
|
||||
const safeProjectId = record.project_id || "";
|
||||
|
||||
return `('${record.link_id}', '${safeOriginalUrl}', '${record.created_at}', '${safeCreatedBy}', '${safeTitle}', '${safeDescription}', ${tagsStr}, ${record.is_active}, ${expiresAt}, '${safeTeamId}', '${safeProjectId}')`;
|
||||
}).join(", ")}
|
||||
`;
|
||||
|
||||
if (clickhouseData.length === 0) {
|
||||
console.log("没有新记录需要插入");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 发送请求到ClickHouse
|
||||
const clickhouseUrl = `${clickhouseConfig.clickhouse_url}`;
|
||||
try {
|
||||
logWithTimestamp("发送插入请求到ClickHouse...");
|
||||
const response = await fetch(clickhouseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clickhouseConfig.clickhouse_user}:${clickhouseConfig.clickhouse_password}`)}`
|
||||
},
|
||||
body: insertSQL,
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`ClickHouse插入错误: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
logWithTimestamp(`成功插入 ${newRecords.length} 条短链接记录到ClickHouse`);
|
||||
return newRecords.length;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logWithTimestamp(`向ClickHouse插入数据失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 批量处理记录
|
||||
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, _id: 1 })
|
||||
.skip(page * batch_size)
|
||||
.limit(batch_size)
|
||||
.toArray();
|
||||
|
||||
if (records.length === 0) {
|
||||
logWithTimestamp(`第 ${page+1} 批次没有找到数据,结束处理`);
|
||||
break;
|
||||
}
|
||||
|
||||
logWithTimestamp(`获取到 ${records.length} 条记录,开始处理...`);
|
||||
// 输出当前批次的部分数据信息
|
||||
if (records.length > 0) {
|
||||
logWithTimestamp(`批次 ${page+1} 第一条记录: ID=${records[0]._id}, Slug=${records[0].slug}, 时间=${new Date(records[0].createTime).toISOString()}`);
|
||||
if (records.length > 1) {
|
||||
logWithTimestamp(`批次 ${page+1} 最后一条记录: ID=${records[records.length-1]._id}, Slug=${records[records.length-1].slug}, 时间=${new Date(records[records.length-1].createTime).toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = await processRecords(records);
|
||||
processedRecords += records.length; // 总是增加处理的记录数,即使有些记录已存在
|
||||
totalBatchRecords += batchSize; // 只增加实际插入的记录数
|
||||
|
||||
logWithTimestamp(`第 ${page+1} 批次处理完成。已处理 ${processedRecords}/${recordsToProcess} 条记录,实际插入 ${totalBatchRecords} 条 (${Math.round(processedRecords/recordsToProcess*100)}%)`);
|
||||
|
||||
// 更新查询条件,以便下一批次查询
|
||||
query.createTime = { $gt: lastCreateTime };
|
||||
if (lastId) {
|
||||
query._id = { $gt: new ObjectId(lastId) };
|
||||
}
|
||||
logWithTimestamp(`更新查询条件: 创建时间 > ${new Date(lastCreateTime).toISOString()}, ID > ${lastId || 'none'}`);
|
||||
}
|
||||
|
||||
// 更新同步状态
|
||||
const newSyncState: SyncState = {
|
||||
last_sync_time: lastCreateTime,
|
||||
records_synced: syncState.records_synced + totalBatchRecords,
|
||||
last_sync_id: lastId
|
||||
};
|
||||
|
||||
await setVariable("f/shorturl_analytics/clickhouse/shorturl_links_sync_state", JSON.stringify(newSyncState));
|
||||
console.log(`同步状态已更新: 最后同步时间 ${new Date(newSyncState.last_sync_time).toISOString()}, 总同步记录数 ${newSyncState.records_synced}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records_processed: processedRecords,
|
||||
records_synced: totalBatchRecords,
|
||||
total_synced: newSyncState.records_synced,
|
||||
last_sync_time: new Date(newSyncState.last_sync_time).toISOString(),
|
||||
message: "短链接数据同步完成"
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("同步过程中发生错误:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
};
|
||||
} finally {
|
||||
// 关闭MongoDB连接
|
||||
await client.close();
|
||||
console.log("MongoDB连接已关闭");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user