Compare commits
93 Commits
chart
...
dafa7f53ac
| Author | SHA1 | Date | |
|---|---|---|---|
| dafa7f53ac | |||
| 0203cb4041 | |||
| ced29201da | |||
| a8c94c9621 | |||
| 4736ebe060 | |||
| 6858f2fda5 | |||
| 42f5be4dcb | |||
| 05af4aae70 | |||
| ed1d2e59f6 | |||
| 3cbb76db36 | |||
| ecef81b0ee | |||
| 9cb85a2910 | |||
| 3af015ca44 | |||
| f6f24d3450 | |||
| 4262f789da | |||
| 2e34cd5b4b | |||
| 2cb45781c7 | |||
| 53e1611670 | |||
| 6025641ab1 | |||
| b9c2828e54 | |||
| b1753449f5 | |||
| 85f29d8b49 | |||
| b8cd3716c4 | |||
| 48d5bdafa4 | |||
| ace231b93f | |||
| e101d19e00 | |||
| a8576121e9 | |||
| 8b407975e5 | |||
| ede83068af | |||
| d21026eafd | |||
| 6940d60510 | |||
| 4e7266240d | |||
| db70602e9f | |||
| d0e83f697b | |||
| ed327ad3f0 | |||
| f782dba0c9 | |||
| 0c4a67e769 | |||
| 694e005101 | |||
| 523e99a001 | |||
| 33dbf62665 | |||
| 1a9e28bd7e | |||
| d1d21948b6 | |||
| f32a45d24a | |||
| d61b8a62ff | |||
| 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 |
27
.env
Normal file
27
.env
Normal file
@@ -0,0 +1,27 @@
|
||||
PORT=3007
|
||||
|
||||
# ClickHouse Configuration
|
||||
CLICKHOUSE_HOST=10.0.1.60
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_USER=admin
|
||||
CLICKHOUSE_PASSWORD=your_secure_password
|
||||
CLICKHOUSE_DATABASE=shorturl_analytics
|
||||
CLICKHOUSE_URL=http://10.0.1.60:8123
|
||||
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_PASSWORD=""
|
||||
|
||||
# Supabase Configuration
|
||||
SUPABASE_URL="https://mwwvqwevplndzvmqmrxa.supabase.co"
|
||||
SUPABASE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im13d3Zxd2V2cGxuZHp2bXFtcnhhIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NDM0NTY0MywiZXhwIjoyMDU5OTIxNjQzfQ.ZenTsEAdGiDu1DCCOT7G8xxvgFXKLl4qhHB-AhSVf6w"
|
||||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im13d3Zxd2V2cGxuZHp2bXFtcnhhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDQzNDU2NDMsImV4cCI6MjA1OTkyMTY0M30.EI7OY0Aq3zYj6fRG_IUn4IlUZ89b0LOg0jb0nMLLKWU"
|
||||
DATABASE_URL="postgresql://postgres.mwwvqwevplndzvmqmrxa:eYYdarJsRL*Z6&p9gD@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
|
||||
|
||||
|
||||
# Next.js Public Environment Variables (accessible in browser)
|
||||
NEXT_PUBLIC_SUPABASE_URL="https://mwwvqwevplndzvmqmrxa.supabase.co"
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im13d3Zxd2V2cGxuZHp2bXFtcnhhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDQzNDU2NDMsImV4cCI6MjA1OTkyMTY0M30.EI7OY0Aq3zYj6fRG_IUn4IlUZ89b0LOg0jb0nMLLKWU"
|
||||
DATABASE_URL="postgresql://postgres.mwwvqwevplndzvmqmrxa:eYYdarJsRL*Z6&p9gD@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
|
||||
|
||||
NEXT_PUBLIC_LIMQ_API="https://app.upj.to"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# .env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
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
|
||||
50
README-google-auth.md
Normal file
50
README-google-auth.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 配置 Google 登录功能
|
||||
|
||||
为了启用 Google 登录功能,您需要在 Supabase 和 Google Cloud Platform 进行配置。
|
||||
|
||||
## 步骤 1: 创建 Google OAuth 客户端
|
||||
|
||||
1. 访问 [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. 创建一个新项目或选择现有项目
|
||||
3. 在左侧菜单中导航到 "API 和服务" > "OAuth 同意屏幕"
|
||||
4. 选择用户类型(外部或内部),然后点击"创建"
|
||||
5. 填写必要的信息(应用名称、用户支持电子邮件等)并保存
|
||||
6. 导航到 "API 和服务" > "凭据"
|
||||
7. 点击"创建凭据" > "OAuth 客户端 ID"
|
||||
8. 应用类型选择 "Web 应用"
|
||||
9. 名称中输入您的应用名称
|
||||
10. 添加以下已获授权的重定向 URI:
|
||||
- `https://mwwvqwevplndzvmqmrxa.supabase.co/auth/v1/callback`
|
||||
11. 点击"创建"
|
||||
12. 复制生成的 "客户端 ID" 和 "客户端密钥"
|
||||
|
||||
## 步骤 2: 在 Supabase 中配置 Google 提供商
|
||||
|
||||
1. 登录 [Supabase 仪表板](https://app.supabase.com)
|
||||
2. 选择您的项目
|
||||
3. 导航到 "身份验证" > "提供商"
|
||||
4. 找到 Google 提供商并启用它
|
||||
5. 粘贴您刚才获取的 "客户端 ID" 和 "客户端密钥"
|
||||
6. 保存配置
|
||||
|
||||
## 步骤 3: 更新重定向 URL(如有需要)
|
||||
|
||||
如果您的应用需要在登录后重定向到特定页面,请确保在 Google Cloud Console 和 Supabase 中配置了正确的重定向 URL。
|
||||
|
||||
在 Supabase 中:
|
||||
1. 导航到 "身份验证" > "URL 配置"
|
||||
2. 添加您的前端 URL 到站点 URL 字段中
|
||||
3. 设置重定向 URL(通常是您的前端 URL)
|
||||
|
||||
## 测试
|
||||
|
||||
1. 在您的应用中,尝试使用 Google 登录
|
||||
2. 验证认证流程,确保可以成功登录并重定向到应用
|
||||
3. 检查 Supabase 中的用户数据,确认新用户已创建
|
||||
|
||||
## 故障排除
|
||||
|
||||
- 确保重定向 URI 完全匹配
|
||||
- 确保 OAuth 同意屏幕已正确配置
|
||||
- 查看 Supabase 和应用程序中的日志以获取详细的错误信息
|
||||
- 如果遇到 CORS 错误,检查您的站点 URL 配置
|
||||
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>
|
||||
);
|
||||
}
|
||||
1120
app/analytics/page.tsx
Normal file
1120
app/analytics/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
236
app/api/activities/route.ts
Normal file
236
app/api/activities/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getEvents } from '@/lib/analytics';
|
||||
import { ApiResponse } from '@/lib/types';
|
||||
|
||||
// 扩展Event类型以包含所需字段
|
||||
interface EventWithFullPath extends Record<string, any> {
|
||||
event_id?: string;
|
||||
event_time?: string;
|
||||
event_type?: string;
|
||||
visitor_id?: string;
|
||||
ip_address?: string;
|
||||
req_full_path?: string;
|
||||
referrer?: string;
|
||||
// 其他可能的字段
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Get parameters
|
||||
const slug = searchParams.get('slug');
|
||||
const domain = searchParams.get('domain');
|
||||
const format = searchParams.get('format');
|
||||
|
||||
// Optional date range parameters
|
||||
const startTime = searchParams.get('startTime') || undefined;
|
||||
const endTime = searchParams.get('endTime') || undefined;
|
||||
|
||||
// 修改验证逻辑,允许只使用时间范围
|
||||
// 现在只需要确保有足够的过滤条件
|
||||
if ((!slug && !domain) && (!startTime && !endTime)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Missing filter parameters: provide either slug/domain or date range'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Construct the shortUrl from domain and slug if both are provided
|
||||
let shortUrl = undefined;
|
||||
if (slug && domain) {
|
||||
shortUrl = `https://${domain}/${slug}`;
|
||||
|
||||
// Log the request for debugging
|
||||
console.log('Activities API received parameters:', {
|
||||
slug,
|
||||
domain,
|
||||
shortUrl,
|
||||
startTime,
|
||||
endTime
|
||||
});
|
||||
} else {
|
||||
console.log('Activities API using time range filter:', {
|
||||
startTime,
|
||||
endTime
|
||||
});
|
||||
}
|
||||
|
||||
// Set default page size and page
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '50');
|
||||
|
||||
// Get events for the specified filters
|
||||
const { events, total } = await getEvents({
|
||||
linkSlug: slug || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
startTime,
|
||||
endTime,
|
||||
sortBy: 'event_time',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
|
||||
// If format=csv, return CSV format data
|
||||
if (format === 'csv') {
|
||||
// CSV header line
|
||||
let csvContent = 'time,activity,campaign,clientId,originPath\n';
|
||||
|
||||
// Helper function to extract utm_campaign from URL
|
||||
const extractUtmCampaign = (url: string | null | undefined): string => {
|
||||
if (!url) return 'demo';
|
||||
|
||||
try {
|
||||
// Try to parse URL and extract utm_campaign parameter
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://example.com${url}`);
|
||||
const campaign = urlObj.searchParams.get('utm_campaign');
|
||||
if (campaign) return campaign;
|
||||
|
||||
// If utm_campaign is not found or URL parsing fails, use regex as fallback
|
||||
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
|
||||
if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
|
||||
} catch (_) {
|
||||
// If URL parsing fails, try regex directly
|
||||
const campaignMatch = url.match(/[?&]utm_campaign=([^&]+)/i);
|
||||
if (campaignMatch && campaignMatch[1]) return campaignMatch[1];
|
||||
}
|
||||
|
||||
return 'demo'; // Default value
|
||||
};
|
||||
|
||||
// Process each event record
|
||||
events.forEach(event => {
|
||||
// 使用类型断言处理扩展字段
|
||||
const eventWithFullPath = event as unknown as EventWithFullPath;
|
||||
|
||||
// Get the full URL from appropriate field
|
||||
// Try different possible fields that might contain the URL
|
||||
const fullUrl = eventWithFullPath.req_full_path || eventWithFullPath.referrer || '';
|
||||
|
||||
// Extract campaign from URL
|
||||
const campaign = extractUtmCampaign(fullUrl);
|
||||
|
||||
// Format time
|
||||
const time = eventWithFullPath.event_time ?
|
||||
new Date(eventWithFullPath.event_time).toISOString().replace('T', ' ').slice(0, 19) :
|
||||
'';
|
||||
|
||||
// Determine activity (event_type)
|
||||
const activity = eventWithFullPath.event_type || '';
|
||||
|
||||
// Client ID (possibly part of visitor_id)
|
||||
const clientId = eventWithFullPath.visitor_id?.split('-')[0] || 'undefined';
|
||||
|
||||
// Original path (use full URL field)
|
||||
const originPath = fullUrl || 'undefined';
|
||||
|
||||
// Add to CSV content
|
||||
csvContent += `${time},${activity},${campaign},${clientId},${originPath}\n`;
|
||||
});
|
||||
|
||||
// Generate filename based on available parameters
|
||||
const filename = slug
|
||||
? `activities-${slug}.csv`
|
||||
: `activities-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
|
||||
// Return CSV response
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process the events to extract useful information
|
||||
const processedEvents = events.map(event => {
|
||||
// Parse JSON strings to objects safely
|
||||
let eventAttributes: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
if (typeof event.event_attributes === 'string') {
|
||||
eventAttributes = JSON.parse(event.event_attributes);
|
||||
} else if (typeof event.event_attributes === 'object') {
|
||||
eventAttributes = event.event_attributes;
|
||||
}
|
||||
} catch {
|
||||
// Keep default empty object if parsing fails
|
||||
}
|
||||
|
||||
// Extract tags
|
||||
let tags: string[] = [];
|
||||
|
||||
try {
|
||||
if (typeof event.link_tags === 'string') {
|
||||
const parsedTags = JSON.parse(event.link_tags);
|
||||
if (Array.isArray(parsedTags)) {
|
||||
tags = parsedTags;
|
||||
}
|
||||
} else if (Array.isArray(event.link_tags)) {
|
||||
tags = event.link_tags;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, keep tags as empty array
|
||||
}
|
||||
|
||||
// Return a simplified event object
|
||||
return {
|
||||
id: event.event_id,
|
||||
type: event.event_type,
|
||||
time: event.event_time,
|
||||
visitor: {
|
||||
id: event.visitor_id,
|
||||
ipAddress: event.ip_address,
|
||||
userAgent: eventAttributes.user_agent as string || null,
|
||||
referrer: eventAttributes.referrer as string || null
|
||||
},
|
||||
device: {
|
||||
type: event.device_type,
|
||||
browser: event.browser,
|
||||
os: event.os
|
||||
},
|
||||
location: {
|
||||
country: event.country,
|
||||
city: event.city
|
||||
},
|
||||
link: {
|
||||
id: event.link_id,
|
||||
slug: event.link_slug,
|
||||
originalUrl: event.link_original_url,
|
||||
label: event.link_label,
|
||||
tags
|
||||
},
|
||||
utm: {
|
||||
source: eventAttributes.utm_source as string || null,
|
||||
medium: eventAttributes.utm_medium as string || null,
|
||||
campaign: eventAttributes.utm_campaign as string || null,
|
||||
term: eventAttributes.utm_term as string || null,
|
||||
content: eventAttributes.utm_content as string || null
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Return processed events
|
||||
const response: ApiResponse<typeof processedEvents> = {
|
||||
success: true,
|
||||
data: processedEvents,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving activities:', error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'An error occurred while retrieving activities'
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/events/devices/route.ts
Normal file
39
app/api/events/devices/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import { getDeviceAnalytics } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// 获取团队、项目和标签筛选参数
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
|
||||
const data = await getDeviceAnalytics({
|
||||
startTime: searchParams.get('startTime') || undefined,
|
||||
endTime: searchParams.get('endTime') || undefined,
|
||||
linkId: searchParams.get('linkId') || undefined,
|
||||
// 添加团队、项目和标签筛选
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
// 添加子路径筛选
|
||||
subpath: searchParams.get('subpath') || undefined
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
app/api/events/geo/route.ts
Normal file
43
app/api/events/geo/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import { getGeoAnalytics } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// 获取团队、项目和标签筛选参数
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
|
||||
// Get the groupBy parameter
|
||||
const groupBy = searchParams.get('groupBy') as 'country' | 'city' | 'region' | 'continent' | null;
|
||||
|
||||
const data = await getGeoAnalytics({
|
||||
startTime: searchParams.get('startTime') || undefined,
|
||||
endTime: searchParams.get('endTime') || undefined,
|
||||
linkId: searchParams.get('linkId') || undefined,
|
||||
groupBy: groupBy || undefined,
|
||||
// 添加团队、项目和标签筛选
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
// 添加子路径筛选
|
||||
subpath: searchParams.get('subpath') || undefined
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
80
app/api/events/path-analytics/route.ts
Normal file
80
app/api/events/path-analytics/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取查询参数
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const startTime = searchParams.get('startTime');
|
||||
const endTime = searchParams.get('endTime');
|
||||
const linkId = searchParams.get('linkId');
|
||||
|
||||
if (!startTime || !endTime || !linkId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Missing required parameters'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 查询链接的点击事件
|
||||
const query = `
|
||||
SELECT event_attributes
|
||||
FROM events
|
||||
WHERE link_id = '${linkId}'
|
||||
AND event_time >= parseDateTimeBestEffort('${startTime}')
|
||||
AND event_time <= parseDateTimeBestEffort('${endTime}')
|
||||
AND event_type = 'click'
|
||||
`;
|
||||
|
||||
const events = await executeQuery(query);
|
||||
|
||||
// 处理事件数据,按路径分组
|
||||
const pathMap = new Map<string, number>();
|
||||
let totalClicks = 0;
|
||||
|
||||
events.forEach((event: any) => {
|
||||
try {
|
||||
if (event.event_attributes) {
|
||||
const attrs = JSON.parse(event.event_attributes);
|
||||
if (attrs.full_url) {
|
||||
// 提取URL的路径和参数部分
|
||||
const url = new URL(attrs.full_url);
|
||||
const pathWithParams = url.pathname + (url.search || '');
|
||||
|
||||
// 更新路径计数
|
||||
const currentCount = pathMap.get(pathWithParams) || 0;
|
||||
pathMap.set(pathWithParams, currentCount + 1);
|
||||
totalClicks++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为数组并按点击数排序
|
||||
const pathData = Array.from(pathMap.entries())
|
||||
.map(([path, count]) => ({
|
||||
path,
|
||||
count,
|
||||
percentage: totalClicks > 0 ? count / totalClicks : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const response: ApiResponse<typeof pathData> = {
|
||||
success: true,
|
||||
data: pathData,
|
||||
meta: { total: totalClicks }
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching path analytics data:', error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
75
app/api/events/route.ts
Normal file
75
app/api/events/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getEvents, EventsQueryParams } from '@/lib/analytics';
|
||||
import { ApiResponse } from '@/lib/types';
|
||||
|
||||
// 获取事件列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取查询参数
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const eventType = searchParams.get('eventType') || undefined;
|
||||
const linkId = searchParams.get('linkId') || undefined;
|
||||
const linkSlug = searchParams.get('linkSlug') || undefined;
|
||||
const userId = searchParams.get('userId') || undefined;
|
||||
const subpath = searchParams.get('subpath') || undefined;
|
||||
|
||||
// 获取可能存在的多个团队、项目和标签ID
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
|
||||
const startTime = searchParams.get('startTime') || undefined;
|
||||
const endTime = searchParams.get('endTime') || undefined;
|
||||
const sortBy = searchParams.get('sortBy') || undefined;
|
||||
const sortOrder = (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined;
|
||||
|
||||
console.log("API接收到的tagIds:", tagIds); // 添加日志便于调试
|
||||
console.log("API接收到的subpath:", subpath); // 添加日志便于调试
|
||||
|
||||
// 获取事件列表
|
||||
const params: EventsQueryParams = {
|
||||
page,
|
||||
pageSize,
|
||||
eventType,
|
||||
linkId,
|
||||
linkSlug,
|
||||
userId,
|
||||
subpath,
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
startTime,
|
||||
endTime,
|
||||
sortBy,
|
||||
sortOrder
|
||||
};
|
||||
|
||||
// 记录完整的参数用于调试
|
||||
console.log("完整请求参数:", JSON.stringify(params));
|
||||
|
||||
const result = await getEvents(params);
|
||||
|
||||
const response: ApiResponse<typeof result.events> = {
|
||||
success: true,
|
||||
data: result.events,
|
||||
meta: {
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('获取事件列表失败:', error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : '获取事件列表失败'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
45
app/api/events/summary/route.ts
Normal file
45
app/api/events/summary/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import { getEventsSummary } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// 获取可能存在的多个团队、项目和标签ID
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
|
||||
// Add debug log to check if linkId is being received
|
||||
const linkId = searchParams.get('linkId');
|
||||
const subpath = searchParams.get('subpath');
|
||||
console.log('Summary API received linkId:', linkId);
|
||||
console.log('Summary API received subpath:', subpath);
|
||||
console.log('Summary API full parameters:', Object.fromEntries(searchParams.entries()));
|
||||
console.log('Summary API URL:', request.url);
|
||||
|
||||
const summary = await getEventsSummary({
|
||||
startTime: searchParams.get('startTime') || undefined,
|
||||
endTime: searchParams.get('endTime') || undefined,
|
||||
linkId: searchParams.get('linkId') || undefined,
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
subpath: searchParams.get('subpath') || undefined
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof summary> = {
|
||||
success: true,
|
||||
data: summary
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
49
app/api/events/time-series/route.ts
Normal file
49
app/api/events/time-series/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import { getTimeSeriesData } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const startTime = searchParams.get('startTime');
|
||||
const endTime = searchParams.get('endTime');
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'startTime and endTime are required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取团队、项目和标签筛选参数
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
|
||||
const data = await getTimeSeriesData({
|
||||
startTime,
|
||||
endTime,
|
||||
linkId: searchParams.get('linkId') || undefined,
|
||||
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month',
|
||||
// 添加团队、项目和标签筛选
|
||||
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||
// 添加子路径筛选
|
||||
subpath: searchParams.get('subpath') || undefined
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
208
app/api/events/track/readme.md
Normal file
208
app/api/events/track/readme.md
Normal file
@@ -0,0 +1,208 @@
|
||||
|
||||
# 事件跟踪接口说明
|
||||
|
||||
## 概述
|
||||
该接口用于跟踪用户交互事件并将数据存储到 ClickHouse 数据库中。支持记录各种类型的事件,并可包含与链接、用户、团队、项目等相关的详细信息。
|
||||
|
||||
## 接口信息
|
||||
- **URL**: `/api/events/track`
|
||||
- **方法**: `POST`
|
||||
- **Content-Type**: `application/json`
|
||||
|
||||
## 请求参数
|
||||
|
||||
### 必填字段
|
||||
| 参数 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| `event_type` | string | 事件类型,如 'click', 'view', 'conversion' |
|
||||
|
||||
### 核心事件字段
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `event_id` | string | 否 | 事件唯一标识符,不提供时自动生成UUID |
|
||||
| `event_time` | string/Date | 否 | 事件发生时间,格式为ISO日期字符串,默认为当前时间 |
|
||||
| `event_attributes` | object/string | 否 | 事件相关的其他属性,可以是JSON对象或JSON字符串 |
|
||||
|
||||
### 链接信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `link_id` | string | 否 | 短链接的唯一ID |
|
||||
| `link_slug` | string | 否 | 短链接的slug部分 |
|
||||
| `link_label` | string | 否 | 短链接的显示名称 |
|
||||
| `link_title` | string | 否 | 短链接的标题 |
|
||||
| `link_original_url` | string | 否 | 原始目标URL |
|
||||
| `link_attributes` | object/string | 否 | 链接相关的额外属性 |
|
||||
| `link_created_at` | string/Date | 否 | 链接创建时间 |
|
||||
| `link_expires_at` | string/Date | 否 | 链接过期时间 |
|
||||
| `link_tags` | array/string | 否 | 链接标签,可以是数组或JSON字符串 |
|
||||
|
||||
### 用户信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `user_id` | string | 否 | 用户ID |
|
||||
| `user_name` | string | 否 | 用户名称 |
|
||||
| `user_email` | string | 否 | 用户邮箱 |
|
||||
| `user_attributes` | object/string | 否 | 用户相关的其他属性 |
|
||||
|
||||
### 团队和项目信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `team_id` | string | 否 | 团队ID |
|
||||
| `team_name` | string | 否 | 团队名称 |
|
||||
| `team_attributes` | object/string | 否 | 团队相关的其他属性 |
|
||||
| `project_id` | string | 否 | 项目ID |
|
||||
| `project_name` | string | 否 | 项目名称 |
|
||||
| `project_attributes` | object/string | 否 | 项目相关的其他属性 |
|
||||
|
||||
### 二维码信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `qr_code_id` | string | 否 | 二维码ID |
|
||||
| `qr_code_name` | string | 否 | 二维码名称 |
|
||||
| `qr_code_attributes` | object/string | 否 | 二维码相关的其他属性 |
|
||||
|
||||
### 访问者信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `visitor_id` | string | 否 | 访问者唯一标识符,不提供时自动生成 |
|
||||
| `session_id` | string | 否 | 会话ID,不提供时自动生成 |
|
||||
| `ip_address` | string | 否 | 访问者IP地址,默认从请求头获取 |
|
||||
| `country` | string | 否 | 访问者所在国家 |
|
||||
| `city` | string | 否 | 访问者所在城市 |
|
||||
| `device_type` | string | 否 | 设备类型 (如 desktop, mobile, tablet) |
|
||||
| `browser` | string | 否 | 浏览器名称 |
|
||||
| `os` | string | 否 | 操作系统 |
|
||||
| `user_agent` | string | 否 | 用户代理字符串,默认从请求头获取 |
|
||||
|
||||
### 引荐来源信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `referrer` | string | 否 | 引荐URL,默认从请求头获取 |
|
||||
| `utm_source` | string | 否 | UTM来源参数 |
|
||||
| `utm_medium` | string | 否 | UTM媒介参数 |
|
||||
| `utm_campaign` | string | 否 | UTM活动参数 |
|
||||
| `utm_term` | string | 否 | UTM术语参数 |
|
||||
| `utm_content` | string | 否 | UTM内容参数 |
|
||||
|
||||
### 交互信息
|
||||
| 参数 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `time_spent_sec` | number | 否 | 用户在页面上停留的时间(秒),默认0 |
|
||||
| `is_bounce` | boolean | 否 | 是否是跳出(只访问一个页面),默认true |
|
||||
| `is_qr_scan` | boolean | 否 | 是否来自二维码扫描,默认false |
|
||||
| `conversion_type` | string | 否 | 转化类型 |
|
||||
| `conversion_value` | number | 否 | 转化价值,默认0 |
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功响应 (201 Created)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Event tracked successfully",
|
||||
"event_id": "uuid-of-tracked-event"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
#### 缺少必填字段 (400 Bad Request)
|
||||
```json
|
||||
{
|
||||
"error": "Missing required field: event_type"
|
||||
}
|
||||
```
|
||||
|
||||
#### 服务器错误 (500 Internal Server Error)
|
||||
```json
|
||||
{
|
||||
"error": "Failed to track event",
|
||||
"details": "具体错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本事件跟踪请求
|
||||
```javascript
|
||||
fetch('/api/events/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event_type: 'click',
|
||||
link_id: 'abc123',
|
||||
link_slug: 'promo-summer',
|
||||
link_original_url: 'https://example.com/summer-promotion'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 详细事件跟踪请求
|
||||
```javascript
|
||||
fetch('/api/events/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event_type: 'conversion',
|
||||
link_id: 'abc123',
|
||||
link_slug: 'promo-summer',
|
||||
link_original_url: 'https://example.com/summer-promotion',
|
||||
event_attributes: {
|
||||
page: '/checkout',
|
||||
product_id: 'xyz789'
|
||||
},
|
||||
user_id: 'user123',
|
||||
team_id: 'team456',
|
||||
project_id: 'proj789',
|
||||
visitor_id: 'vis987',
|
||||
is_bounce: false,
|
||||
time_spent_sec: 120,
|
||||
conversion_type: 'purchase',
|
||||
conversion_value: 99.99,
|
||||
utm_source: 'email',
|
||||
utm_campaign: 'summer_sale'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 所有对象类型的字段(如 `event_attributes`)可以作为对象或预先格式化的JSON字符串传递
|
||||
- 如果不提供 `event_id`、`visitor_id` 或 `session_id`,系统将自动生成
|
||||
- 时间戳字段接受ISO格式的日期字符串,并会被转换为ClickHouse兼容的格式
|
||||
|
||||
|
||||
UTM 测试示例。1. 电子邮件营销链接
|
||||
https://short.domain.com/summer?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header
|
||||
说明: 用于电子邮件营销活动,跟踪用户从邮件头部横幅点击的流量。
|
||||
|
||||
2. 社交媒体广告链接
|
||||
https://short.domain.com/product?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story
|
||||
说明: 用于 Instagram Story 广告,跟踪用户从社交媒体故事广告点击的情况。
|
||||
|
||||
3. 搜索引擎广告链接
|
||||
https://short.domain.com/service?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name
|
||||
说明: 用于 Google Ads 广告,跟踪用户从搜索引擎付费广告点击的流量,特别是针对特定搜索词。
|
||||
|
||||
4. QR 码链接
|
||||
https://short.domain.com/event?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr
|
||||
说明: 用于打印材料上的 QR 码,跟踪用户扫描实体宣传资料的情况。
|
||||
|
||||
5. 合作伙伴引荐链接
|
||||
https://short.domain.com/partner?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner
|
||||
说明: 用于合作伙伴网站上的推广横幅,跟踪来自联盟营销的转化率。
|
||||
|
||||
|
||||
https://upj.to/5seaii?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header
|
||||
|
||||
https://upj.to/5seaii?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story
|
||||
|
||||
https://upj.to/5seaii?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name
|
||||
|
||||
|
||||
https://upj.to/5seaii?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr
|
||||
|
||||
https://upj.to/5seaii?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner
|
||||
139
app/api/events/track/route.ts
Normal file
139
app/api/events/track/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Event } from '../../types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
|
||||
// 将时间格式化为ClickHouse兼容的格式:YYYY-MM-DD HH:MM:SS.SSS
|
||||
const formatDateTime = (date: Date) => {
|
||||
return date.toISOString().replace('T', ' ').replace('Z', '');
|
||||
};
|
||||
|
||||
// Handler for POST request to track events
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
const eventData = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!eventData.event_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required field: event_type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前时间并格式化
|
||||
const currentTime = formatDateTime(new Date());
|
||||
|
||||
// Set default values for required fields if missing
|
||||
const event: Event = {
|
||||
// Core event fields
|
||||
event_id: eventData.event_id || uuid(),
|
||||
event_time: eventData.event_time ? formatDateTime(new Date(eventData.event_time)) : currentTime,
|
||||
event_type: eventData.event_type,
|
||||
event_attributes: eventData.event_attributes || '{}',
|
||||
|
||||
// Link information
|
||||
link_id: eventData.link_id || '',
|
||||
link_slug: eventData.link_slug || '',
|
||||
link_label: eventData.link_label || '',
|
||||
link_title: eventData.link_title || '',
|
||||
link_original_url: eventData.link_original_url || '',
|
||||
link_attributes: eventData.link_attributes || '{}',
|
||||
link_created_at: eventData.link_created_at ? formatDateTime(new Date(eventData.link_created_at)) : currentTime,
|
||||
link_expires_at: eventData.link_expires_at ? formatDateTime(new Date(eventData.link_expires_at)) : null,
|
||||
link_tags: eventData.link_tags || '[]',
|
||||
|
||||
// User information
|
||||
user_id: eventData.user_id || '',
|
||||
user_name: eventData.user_name || '',
|
||||
user_email: eventData.user_email || '',
|
||||
user_attributes: eventData.user_attributes || '{}',
|
||||
|
||||
// Team information
|
||||
team_id: eventData.team_id || '',
|
||||
team_name: eventData.team_name || '',
|
||||
team_attributes: eventData.team_attributes || '{}',
|
||||
|
||||
// Project information
|
||||
project_id: eventData.project_id || '',
|
||||
project_name: eventData.project_name || '',
|
||||
project_attributes: eventData.project_attributes || '{}',
|
||||
|
||||
// QR code information
|
||||
qr_code_id: eventData.qr_code_id || '',
|
||||
qr_code_name: eventData.qr_code_name || '',
|
||||
qr_code_attributes: eventData.qr_code_attributes || '{}',
|
||||
|
||||
// Visitor information
|
||||
visitor_id: eventData.visitor_id || uuid(),
|
||||
session_id: eventData.session_id || uuid(),
|
||||
ip_address: eventData.ip_address || req.headers.get('x-forwarded-for')?.toString() || '',
|
||||
country: eventData.country || '',
|
||||
city: eventData.city || '',
|
||||
device_type: eventData.device_type || '',
|
||||
browser: eventData.browser || '',
|
||||
os: eventData.os || '',
|
||||
user_agent: eventData.user_agent || req.headers.get('user-agent')?.toString() || '',
|
||||
|
||||
// Referrer information
|
||||
referrer: eventData.referrer || req.headers.get('referer')?.toString() || '',
|
||||
utm_source: eventData.utm_source || '',
|
||||
utm_medium: eventData.utm_medium || '',
|
||||
utm_campaign: eventData.utm_campaign || '',
|
||||
utm_term: eventData.utm_term || '',
|
||||
utm_content: eventData.utm_content || '',
|
||||
|
||||
// Interaction information
|
||||
time_spent_sec: eventData.time_spent_sec || 0,
|
||||
is_bounce: eventData.is_bounce !== undefined ? eventData.is_bounce : true,
|
||||
is_qr_scan: eventData.is_qr_scan !== undefined ? eventData.is_qr_scan : false,
|
||||
conversion_type: eventData.conversion_type || '',
|
||||
conversion_value: eventData.conversion_value || 0,
|
||||
};
|
||||
|
||||
// 确保JSON字符串字段的正确处理
|
||||
if (typeof event.event_attributes === 'object') {
|
||||
event.event_attributes = JSON.stringify(event.event_attributes);
|
||||
}
|
||||
if (typeof event.link_attributes === 'object') {
|
||||
event.link_attributes = JSON.stringify(event.link_attributes);
|
||||
}
|
||||
if (typeof event.user_attributes === 'object') {
|
||||
event.user_attributes = JSON.stringify(event.user_attributes);
|
||||
}
|
||||
if (typeof event.team_attributes === 'object') {
|
||||
event.team_attributes = JSON.stringify(event.team_attributes);
|
||||
}
|
||||
if (typeof event.project_attributes === 'object') {
|
||||
event.project_attributes = JSON.stringify(event.project_attributes);
|
||||
}
|
||||
if (typeof event.qr_code_attributes === 'object') {
|
||||
event.qr_code_attributes = JSON.stringify(event.qr_code_attributes);
|
||||
}
|
||||
if (typeof event.link_tags === 'object') {
|
||||
event.link_tags = JSON.stringify(event.link_tags);
|
||||
}
|
||||
|
||||
// Insert event into ClickHouse
|
||||
await clickhouse.insert({
|
||||
table: 'events',
|
||||
values: [event],
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
// Return success response
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Event tracked successfully',
|
||||
event_id: event.event_id
|
||||
}, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error tracking event:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to track event', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
app/api/events/utm/route.ts
Normal file
201
app/api/events/utm/route.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
|
||||
interface UtmData {
|
||||
utm_value: string;
|
||||
clicks: number;
|
||||
visitors: number;
|
||||
avg_time_spent: number;
|
||||
bounces: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
// 辅助函数,将日期格式化为标准格式
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString().split('.')[0];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// 获取过滤参数
|
||||
const startTime = searchParams.get('startTime');
|
||||
const endTime = searchParams.get('endTime');
|
||||
const linkId = searchParams.get('linkId');
|
||||
const subpath = searchParams.get('subpath');
|
||||
|
||||
// 获取团队、项目和标签筛选参数
|
||||
const teamIds = searchParams.getAll('teamId');
|
||||
const projectIds = searchParams.getAll('projectId');
|
||||
const tagIds = searchParams.getAll('tagId');
|
||||
const tagNames = searchParams.getAll('tagName');
|
||||
|
||||
// 获取UTM类型参数
|
||||
const utmType = searchParams.get('utmType') || 'source';
|
||||
|
||||
// 添加调试日志
|
||||
console.log('UTM API received parameters:', {
|
||||
startTime,
|
||||
endTime,
|
||||
linkId,
|
||||
subpath,
|
||||
teamIds,
|
||||
projectIds,
|
||||
tagIds,
|
||||
tagNames,
|
||||
utmType,
|
||||
url: request.url
|
||||
});
|
||||
|
||||
// 构建WHERE子句
|
||||
let whereClause = '';
|
||||
const conditions = [];
|
||||
|
||||
if (startTime) {
|
||||
conditions.push(`event_time >= toDateTime('${formatDateTime(startTime)}')`);
|
||||
}
|
||||
|
||||
if (endTime) {
|
||||
conditions.push(`event_time <= toDateTime('${formatDateTime(endTime)}')`);
|
||||
}
|
||||
|
||||
if (linkId) {
|
||||
conditions.push(`link_id = '${linkId}'`);
|
||||
}
|
||||
|
||||
// 添加子路径筛选 - 使用更精确的匹配方式
|
||||
if (subpath && subpath.trim() !== '') {
|
||||
console.log('====== UTM API SUBPATH DEBUG ======');
|
||||
console.log('Raw subpath param:', subpath);
|
||||
|
||||
// 清理并准备subpath值
|
||||
let cleanSubpath = subpath.trim();
|
||||
// 移除开头的斜杠以便匹配
|
||||
if (cleanSubpath.startsWith('/')) {
|
||||
cleanSubpath = cleanSubpath.substring(1);
|
||||
}
|
||||
// 移除结尾的斜杠以便匹配
|
||||
if (cleanSubpath.endsWith('/')) {
|
||||
cleanSubpath = cleanSubpath.substring(0, cleanSubpath.length - 1);
|
||||
}
|
||||
|
||||
console.log('Cleaned subpath:', cleanSubpath);
|
||||
|
||||
// 使用正则表达式匹配URL中的第二个路径部分
|
||||
// 示例: 在 "https://abc.com/slug/subpath/" 中匹配 "subpath"
|
||||
const condition = `match(JSONExtractString(event_attributes, 'full_url'), '/[^/]+/${cleanSubpath}(/|\\\\?|$)')`;
|
||||
|
||||
console.log('Final SQL condition:', condition);
|
||||
console.log('==================================');
|
||||
|
||||
conditions.push(condition);
|
||||
}
|
||||
|
||||
// 添加团队筛选
|
||||
if (teamIds && teamIds.length > 0) {
|
||||
// 如果只有一个团队ID
|
||||
if (teamIds.length === 1) {
|
||||
conditions.push(`team_id = '${teamIds[0]}'`);
|
||||
} else {
|
||||
// 多个团队ID
|
||||
conditions.push(`team_id IN ('${teamIds.join("','")}')`);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加项目筛选
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
// 如果只有一个项目ID
|
||||
if (projectIds.length === 1) {
|
||||
conditions.push(`project_id = '${projectIds[0]}'`);
|
||||
} else {
|
||||
// 多个项目ID
|
||||
conditions.push(`project_id IN ('${projectIds.join("','")}')`);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加标签筛选
|
||||
if ((tagIds && tagIds.length > 0) || (tagNames && tagNames.length > 0)) {
|
||||
// 优先使用tagNames,如果有的话
|
||||
const tagsToUse = tagNames.length > 0 ? tagNames : tagIds;
|
||||
|
||||
// 使用与buildFilter函数相同的处理方式
|
||||
const tagConditions = tagsToUse.map(tag =>
|
||||
`link_tags LIKE '%${tag}%'`
|
||||
);
|
||||
conditions.push(`(${tagConditions.join(' OR ')})`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
// 确定要分组的UTM字段
|
||||
let utmField;
|
||||
switch (utmType) {
|
||||
case 'source':
|
||||
utmField = 'utm_source';
|
||||
break;
|
||||
case 'medium':
|
||||
utmField = 'utm_medium';
|
||||
break;
|
||||
case 'campaign':
|
||||
utmField = 'utm_campaign';
|
||||
break;
|
||||
case 'term':
|
||||
utmField = 'utm_term';
|
||||
break;
|
||||
case 'content':
|
||||
utmField = 'utm_content';
|
||||
break;
|
||||
default:
|
||||
utmField = 'utm_source';
|
||||
}
|
||||
|
||||
// 构建SQL查询
|
||||
const query = `
|
||||
SELECT
|
||||
${utmField} AS utm_value,
|
||||
COUNT(*) AS clicks,
|
||||
uniqExact(visitor_id) AS visitors,
|
||||
round(AVG(time_spent_sec), 2) AS avg_time_spent,
|
||||
countIf(is_bounce = 1) AS bounces,
|
||||
countIf(conversion_type IN ('visit', 'stay', 'interact', 'signup', 'subscription', 'purchase')) AS conversions
|
||||
FROM shorturl_analytics.events
|
||||
${whereClause}
|
||||
${whereClause ? 'AND' : 'WHERE'} ${utmField} != ''
|
||||
GROUP BY utm_value
|
||||
ORDER BY clicks DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
// 执行查询
|
||||
const result = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
// 获取查询结果
|
||||
const rows = await result.json();
|
||||
const data = rows as UtmData[];
|
||||
|
||||
// 返回数据
|
||||
const response: ApiResponse<UtmData[]> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching UTM data:', error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
248
app/api/geo/batch/route.ts
Normal file
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);
|
||||
}
|
||||
141
app/api/shortlinks/[id]/route.ts
Normal file
141
app/api/shortlinks/[id]/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
// Get the id from the URL parameters
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'ID parameter is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('Fetching shortlink by ID:', id);
|
||||
|
||||
// Query to fetch a single shortlink by id
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
external_id,
|
||||
type,
|
||||
slug,
|
||||
original_url,
|
||||
title,
|
||||
description,
|
||||
attributes,
|
||||
schema_version,
|
||||
creator_id,
|
||||
creator_email,
|
||||
creator_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
projects,
|
||||
teams,
|
||||
tags,
|
||||
qr_codes AS qr_codes,
|
||||
channels,
|
||||
favorites,
|
||||
expires_at,
|
||||
click_count,
|
||||
unique_visitors,
|
||||
domain
|
||||
FROM shorturl_analytics.shorturl
|
||||
WHERE id = '${id}' AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
console.log('Executing query:', query);
|
||||
|
||||
// Execute the query
|
||||
const result = await executeQuery(query);
|
||||
|
||||
// If no shortlink found with the specified ID
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Shortlink not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Process the shortlink data
|
||||
const shortlink = result[0] as any;
|
||||
|
||||
// Extract shortUrl from attributes
|
||||
let shortUrl = '';
|
||||
try {
|
||||
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||
const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string };
|
||||
shortUrl = attributes.shortUrl || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing shortlink attributes:', e);
|
||||
}
|
||||
|
||||
// Process teams
|
||||
let teams: any[] = [];
|
||||
try {
|
||||
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||
teams = JSON.parse(shortlink.teams);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing teams:', e);
|
||||
}
|
||||
|
||||
// Process tags
|
||||
let tags: any[] = [];
|
||||
try {
|
||||
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||
tags = JSON.parse(shortlink.tags);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing tags:', e);
|
||||
}
|
||||
|
||||
// Process projects
|
||||
let projects: any[] = [];
|
||||
try {
|
||||
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||
projects = JSON.parse(shortlink.projects);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing projects:', e);
|
||||
}
|
||||
|
||||
// Format the data to match what our store expects
|
||||
const formattedShortlink = {
|
||||
id: shortlink.id || '',
|
||||
externalId: shortlink.external_id || '',
|
||||
slug: shortlink.slug || '',
|
||||
originalUrl: shortlink.original_url || '',
|
||||
title: shortlink.title || '',
|
||||
shortUrl: shortUrl,
|
||||
teams: teams,
|
||||
projects: projects,
|
||||
tags: tags.map((tag: any) => tag.tag_name || ''),
|
||||
createdAt: shortlink.created_at,
|
||||
domain: shortlink.domain || (shortUrl ? new URL(shortUrl).hostname : '')
|
||||
};
|
||||
|
||||
const response: ApiResponse<typeof formattedShortlink> = {
|
||||
success: true,
|
||||
data: formattedShortlink
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shortlink by ID:', error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
143
app/api/shortlinks/byUrl/route.ts
Normal file
143
app/api/shortlinks/byUrl/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the url from query parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'URL parameter is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('Fetching shortlink by URL:', url);
|
||||
|
||||
// Query to fetch a single shortlink by shortUrl in attributes
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
external_id,
|
||||
type,
|
||||
slug,
|
||||
original_url,
|
||||
title,
|
||||
description,
|
||||
attributes,
|
||||
schema_version,
|
||||
creator_id,
|
||||
creator_email,
|
||||
creator_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
projects,
|
||||
teams,
|
||||
tags,
|
||||
qr_codes AS qr_codes,
|
||||
channels,
|
||||
favorites,
|
||||
expires_at,
|
||||
click_count,
|
||||
unique_visitors,
|
||||
domain
|
||||
FROM shorturl_analytics.shorturl
|
||||
WHERE JSONHas(attributes, 'shortUrl')
|
||||
AND JSONExtractString(attributes, 'shortUrl') = '${url}'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
console.log('Executing query:', query);
|
||||
|
||||
// Execute the query
|
||||
const result = await executeQuery(query);
|
||||
|
||||
// If no shortlink found with the specified URL
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Shortlink not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Process the shortlink data
|
||||
const shortlink = result[0];
|
||||
|
||||
// Extract shortUrl from attributes
|
||||
let shortUrl = '';
|
||||
try {
|
||||
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||
const attributes = JSON.parse(shortlink.attributes);
|
||||
shortUrl = attributes.shortUrl || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing shortlink attributes:', e);
|
||||
}
|
||||
|
||||
// Process teams
|
||||
let teams = [];
|
||||
try {
|
||||
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||
teams = JSON.parse(shortlink.teams);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing teams:', e);
|
||||
}
|
||||
|
||||
// Process tags
|
||||
let tags = [];
|
||||
try {
|
||||
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||
tags = JSON.parse(shortlink.tags);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing tags:', e);
|
||||
}
|
||||
|
||||
// Process projects
|
||||
let projects = [];
|
||||
try {
|
||||
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||
projects = JSON.parse(shortlink.projects);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing projects:', e);
|
||||
}
|
||||
|
||||
// Format the data to match what our store expects
|
||||
const formattedShortlink = {
|
||||
id: shortlink.id || '',
|
||||
externalId: shortlink.external_id || '',
|
||||
slug: shortlink.slug || '',
|
||||
originalUrl: shortlink.original_url || '',
|
||||
title: shortlink.title || '',
|
||||
shortUrl: shortUrl,
|
||||
teams: teams,
|
||||
projects: projects,
|
||||
tags: tags.map((tag) => tag.tag_name || ''),
|
||||
createdAt: shortlink.created_at,
|
||||
domain: shortlink.domain || (shortUrl ? new URL(shortUrl).hostname : '')
|
||||
};
|
||||
|
||||
console.log('Shortlink data formatted with externalId:', shortlink.external_id, 'Final object:', formattedShortlink);
|
||||
|
||||
const response: ApiResponse<typeof formattedShortlink> = {
|
||||
success: true,
|
||||
data: formattedShortlink
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shortlink by URL:', error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
143
app/api/shortlinks/exact/route.ts
Normal file
143
app/api/shortlinks/exact/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the url from query parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shortUrl = searchParams.get('shortUrl');
|
||||
|
||||
if (!shortUrl) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'shortUrl parameter is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('Fetching shortlink by exact shortUrl:', shortUrl);
|
||||
|
||||
// Query to fetch a single shortlink by shortUrl in attributes
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
external_id,
|
||||
type,
|
||||
slug,
|
||||
original_url,
|
||||
title,
|
||||
description,
|
||||
attributes,
|
||||
schema_version,
|
||||
creator_id,
|
||||
creator_email,
|
||||
creator_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
projects,
|
||||
teams,
|
||||
tags,
|
||||
qr_codes AS qr_codes,
|
||||
channels,
|
||||
favorites,
|
||||
expires_at,
|
||||
click_count,
|
||||
unique_visitors,
|
||||
domain
|
||||
FROM shorturl_analytics.shorturl
|
||||
WHERE JSONHas(attributes, 'shortUrl')
|
||||
AND JSONExtractString(attributes, 'shortUrl') = '${shortUrl}'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
console.log('Executing query:', query);
|
||||
|
||||
// Execute the query
|
||||
const result = await executeQuery(query);
|
||||
|
||||
// If no shortlink found with the specified URL
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Shortlink not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Process the shortlink data
|
||||
const shortlink = result[0] as Record<string, any>;
|
||||
|
||||
// Extract shortUrl from attributes
|
||||
let shortUrlValue = '';
|
||||
try {
|
||||
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||
const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string };
|
||||
shortUrlValue = attributes.shortUrl || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing shortlink attributes:', e);
|
||||
}
|
||||
|
||||
// Process teams
|
||||
let teams: any[] = [];
|
||||
try {
|
||||
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||
teams = JSON.parse(shortlink.teams);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing teams:', e);
|
||||
}
|
||||
|
||||
// Process tags
|
||||
let tags: any[] = [];
|
||||
try {
|
||||
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||
tags = JSON.parse(shortlink.tags);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing tags:', e);
|
||||
}
|
||||
|
||||
// Process projects
|
||||
let projects: any[] = [];
|
||||
try {
|
||||
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||
projects = JSON.parse(shortlink.projects);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing projects:', e);
|
||||
}
|
||||
|
||||
// Format the data to match what our store expects
|
||||
const formattedShortlink = {
|
||||
id: shortlink.id || '',
|
||||
externalId: shortlink.external_id || '',
|
||||
slug: shortlink.slug || '',
|
||||
originalUrl: shortlink.original_url || '',
|
||||
title: shortlink.title || '',
|
||||
shortUrl: shortUrlValue,
|
||||
teams: teams,
|
||||
projects: projects,
|
||||
tags: tags.map((tag: any) => tag.tag_name || ''),
|
||||
createdAt: shortlink.created_at,
|
||||
domain: shortlink.domain || (shortUrlValue ? new URL(shortUrlValue).hostname : '')
|
||||
};
|
||||
|
||||
console.log('Formatted shortlink with externalId:', shortlink.external_id);
|
||||
|
||||
const response: ApiResponse<typeof formattedShortlink> = {
|
||||
success: true,
|
||||
data: formattedShortlink
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shortlink by exact URL:', error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
104
app/api/shortlinks/route.ts
Normal file
104
app/api/shortlinks/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get pagination and filter parameters from the URL
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(searchParams.get('page_size') || '10', 10);
|
||||
const search = searchParams.get('search');
|
||||
const team = searchParams.get('team');
|
||||
|
||||
// Calculate OFFSET
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions = ['deleted_at IS NULL'];
|
||||
|
||||
if (search) {
|
||||
// Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url
|
||||
whereConditions.push(`(
|
||||
slug ILIKE '%${search}%' OR
|
||||
original_url ILIKE '%${search}%' OR
|
||||
title ILIKE '%${search}%' OR
|
||||
JSONHas(attributes, 'shortUrl') AND JSONExtractString(attributes, 'shortUrl') ILIKE '%${search}%' OR
|
||||
arrayExists(x -> JSONExtractString(x, 'team_name') ILIKE '%${search}%', JSONExtractArrayRaw(teams)) OR
|
||||
arrayExists(x -> JSONExtractString(x, 'tag_name') ILIKE '%${search}%', JSONExtractArrayRaw(tags))
|
||||
)`);
|
||||
}
|
||||
|
||||
if (team) {
|
||||
whereConditions.push(`arrayExists(x -> JSONExtractString(x, 'team_id') = '${team}', JSONExtractArrayRaw(teams))`);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// First query to get total count
|
||||
const countQuery = `
|
||||
SELECT count(*) as total
|
||||
FROM shorturl_analytics.shorturl
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const countResult = await executeQuery(countQuery);
|
||||
// Handle the result safely by using an explicit type check
|
||||
const total = Array.isArray(countResult) && countResult.length > 0 && typeof countResult[0] === 'object' && countResult[0] !== null && 'total' in countResult[0]
|
||||
? Number(countResult[0].total)
|
||||
: 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Main query with pagination
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
external_id,
|
||||
type,
|
||||
slug,
|
||||
original_url,
|
||||
title,
|
||||
description,
|
||||
attributes,
|
||||
schema_version,
|
||||
creator_id,
|
||||
creator_email,
|
||||
creator_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
projects,
|
||||
teams,
|
||||
tags,
|
||||
qr_codes AS qr_codes,
|
||||
channels,
|
||||
favorites,
|
||||
expires_at,
|
||||
click_count,
|
||||
unique_visitors,
|
||||
domain
|
||||
FROM shorturl_analytics.shorturl
|
||||
WHERE ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// Execute the query using the shared client
|
||||
const rows = await executeQuery(query);
|
||||
|
||||
// Return the data with pagination metadata
|
||||
return NextResponse.json({
|
||||
links: rows,
|
||||
total: total,
|
||||
total_pages: totalPages,
|
||||
page: page,
|
||||
page_size: pageSize
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching shortlinks from ClickHouse:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch shortlinks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
335
app/api/types.ts
335
app/api/types.ts
@@ -1,221 +1,168 @@
|
||||
// 链接数据类型
|
||||
export interface Link {
|
||||
// Event Types
|
||||
export interface Event {
|
||||
// 核心事件信息
|
||||
event_id: string;
|
||||
event_time: string;
|
||||
event_type: string;
|
||||
event_attributes: string;
|
||||
|
||||
// 链接信息
|
||||
link_id: string;
|
||||
original_url: string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
link_slug: string;
|
||||
link_label: string;
|
||||
link_title: string;
|
||||
link_original_url: string;
|
||||
link_attributes: string;
|
||||
link_created_at: string;
|
||||
link_expires_at: string | null;
|
||||
link_tags: string;
|
||||
|
||||
// 用户信息
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_attributes: string;
|
||||
|
||||
// 团队信息
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
team_attributes: string;
|
||||
|
||||
// 项目信息
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
project_attributes: string;
|
||||
|
||||
// 二维码信息
|
||||
qr_code_id: string;
|
||||
qr_code_name: string;
|
||||
qr_code_attributes: string;
|
||||
|
||||
// 访问者信息
|
||||
visitor_id: string;
|
||||
session_id: string;
|
||||
ip_address: string;
|
||||
country: string;
|
||||
city: string;
|
||||
device_type: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
user_agent: string;
|
||||
|
||||
// 来源信息
|
||||
referrer: string;
|
||||
utm_source: string;
|
||||
utm_medium: string;
|
||||
utm_campaign: string;
|
||||
utm_term: string;
|
||||
utm_content: string;
|
||||
|
||||
// 交互信息
|
||||
time_spent_sec: number;
|
||||
is_bounce: boolean;
|
||||
is_qr_scan: boolean;
|
||||
conversion_type: string;
|
||||
conversion_value: number;
|
||||
|
||||
// 旧接口兼容字段
|
||||
id?: string;
|
||||
time?: string;
|
||||
type?: string;
|
||||
linkInfo?: {
|
||||
id: string;
|
||||
shortUrl: string;
|
||||
originalUrl: string;
|
||||
};
|
||||
visitor?: {
|
||||
id: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
device: string;
|
||||
};
|
||||
location?: {
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Analytics Types
|
||||
export interface TimeSeriesData {
|
||||
timestamp: string;
|
||||
events: number;
|
||||
visitors: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
export interface GeoData {
|
||||
location: string;
|
||||
area: string;
|
||||
visits: number;
|
||||
unique_visits: number;
|
||||
visitors: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
}
|
||||
export type DeviceType = 'mobile' | 'desktop' | 'tablet' | 'other';
|
||||
|
||||
// 链接查询参数
|
||||
export interface LinkQueryParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
searchTerm?: string;
|
||||
tagFilter?: string;
|
||||
isActive?: boolean | null;
|
||||
}
|
||||
|
||||
// 标签类型
|
||||
export interface Tag {
|
||||
tag: string;
|
||||
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 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;
|
||||
}
|
||||
|
||||
export interface FunnelStep {
|
||||
browsers: {
|
||||
name: string;
|
||||
value: number;
|
||||
percent: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
operatingSystems: {
|
||||
name: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ConversionFunnelData {
|
||||
steps: FunnelStep[];
|
||||
export interface ConversionStats {
|
||||
totalConversions: number;
|
||||
conversionRate: 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 {
|
||||
averageValue: number;
|
||||
byType: {
|
||||
type: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
percentage: number;
|
||||
value: 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;
|
||||
}
|
||||
17
app/auth/callback/route.ts
Normal file
17
app/auth/callback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const code = requestUrl.searchParams.get('code');
|
||||
|
||||
if (code) {
|
||||
const cookieStore = cookies();
|
||||
const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
// URL to redirect to after sign in process completes
|
||||
return NextResponse.redirect(new URL('/analytics', request.url));
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
162
app/components/analytics/PathAnalytics.tsx
Normal file
162
app/components/analytics/PathAnalytics.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface PathAnalyticsProps {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
linkId?: string;
|
||||
onPathClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface PathData {
|
||||
path: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const PathAnalytics: React.FC<PathAnalyticsProps> = ({ startTime, endTime, linkId, onPathClick }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pathData, setPathData] = useState<PathData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPathData = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
startTime,
|
||||
endTime,
|
||||
linkId
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/events/path-analytics?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch path analytics data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 自定义处理路径数据,根据是否有子路径来分组
|
||||
const rawData = result.data;
|
||||
const pathMap = new Map<string, number>();
|
||||
let totalClicks = 0;
|
||||
|
||||
rawData.forEach((item: PathData) => {
|
||||
const urlPath = item.path.split('?')[0];
|
||||
totalClicks += item.count;
|
||||
|
||||
// 解析路径,检查是否有子路径
|
||||
const pathParts = urlPath.split('/').filter(Boolean);
|
||||
|
||||
// 基础路径(例如/5seaii)或者带有查询参数但没有子路径的路径视为同一个路径
|
||||
// 子路径(例如/5seaii/bbbbb)单独统计
|
||||
const groupKey = pathParts.length > 1 ? urlPath : `/${pathParts[0]}`;
|
||||
|
||||
const currentCount = pathMap.get(groupKey) || 0;
|
||||
pathMap.set(groupKey, currentCount + item.count);
|
||||
});
|
||||
|
||||
// 转换回数组并排序
|
||||
const groupedPathData = Array.from(pathMap.entries())
|
||||
.map(([path, count]) => ({
|
||||
path,
|
||||
count,
|
||||
percentage: totalClicks > 0 ? count / totalClicks : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setPathData(groupedPathData);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load path analytics');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPathData();
|
||||
}, [startTime, endTime, linkId]);
|
||||
|
||||
const handlePathClick = (path: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('====== PATH CLICK DEBUG ======');
|
||||
console.log('Path value:', path);
|
||||
console.log('Path type:', typeof path);
|
||||
console.log('Path length:', path.length);
|
||||
console.log('Path chars:', Array.from(path).map(c => c.charCodeAt(0)));
|
||||
console.log('==============================');
|
||||
if (onPathClick) {
|
||||
onPathClick(path);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-8 flex justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="py-4 text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!linkId) {
|
||||
return <div className="py-4 text-gray-500">Select a specific link to view path analytics.</div>;
|
||||
}
|
||||
|
||||
if (pathData.length === 0) {
|
||||
return <div className="py-4 text-gray-500">No path data available for this link.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
Note: Paths are grouped by subpath. URLs with different query parameters but the same base path (without subpath) are grouped together.
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
|
||||
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Clicks</th>
|
||||
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pathData.map((item, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-blue-600 hover:underline cursor-pointer"
|
||||
onClick={(e) => handlePathClick(item.path, e)}
|
||||
>
|
||||
{item.path}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{item.count}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-gray-500 mr-2">{(item.percentage * 100).toFixed(1)}%</span>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${item.percentage * 100}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PathAnalytics;
|
||||
205
app/components/analytics/UtmAnalytics.tsx
Normal file
205
app/components/analytics/UtmAnalytics.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UtmData {
|
||||
utm_value: string;
|
||||
clicks: number;
|
||||
visitors: number;
|
||||
avg_time_spent: number;
|
||||
bounces: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
interface UtmAnalyticsProps {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
linkId?: string;
|
||||
teamIds?: string[];
|
||||
projectIds?: string[];
|
||||
tagIds?: string[];
|
||||
subpath?: string;
|
||||
}
|
||||
|
||||
export default function UtmAnalytics({ startTime, endTime, linkId, teamIds, projectIds, tagIds, subpath }: UtmAnalyticsProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>('source');
|
||||
const [utmData, setUtmData] = useState<UtmData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 加载UTM数据
|
||||
useEffect(() => {
|
||||
const fetchUtmData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 构建URL参数
|
||||
const params = new URLSearchParams();
|
||||
if (startTime) params.append('startTime', startTime);
|
||||
if (endTime) params.append('endTime', endTime);
|
||||
if (linkId) params.append('linkId', linkId);
|
||||
if (subpath) params.append('subpath', subpath);
|
||||
params.append('utmType', activeTab);
|
||||
|
||||
// 添加团队ID参数
|
||||
if (teamIds && teamIds.length > 0) {
|
||||
teamIds.forEach(id => params.append('teamId', id));
|
||||
}
|
||||
|
||||
// 添加项目ID参数
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
projectIds.forEach(id => params.append('projectId', id));
|
||||
}
|
||||
|
||||
// 添加标签名称参数
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
tagIds.forEach(tagName => params.append('tagName', tagName));
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(`/api/events/utm?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch UTM data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setUtmData(result.data || []);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to fetch UTM data');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
console.error('Error fetching UTM data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUtmData();
|
||||
}, [activeTab, startTime, endTime, linkId, teamIds, projectIds, tagIds, subpath]);
|
||||
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">UTM Parameters</h2>
|
||||
|
||||
<div className="mb-4 border-b">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('source')}
|
||||
className={`px-4 py-2 ${activeTab === 'source' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('medium')}
|
||||
className={`px-4 py-2 ${activeTab === 'medium' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('campaign')}
|
||||
className={`px-4 py-2 ${activeTab === 'campaign' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
Campaign
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('term')}
|
||||
className={`px-4 py-2 ${activeTab === 'term' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
Term
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('content')}
|
||||
className={`px-4 py-2 ${activeTab === 'content' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-gray-500">Loading...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Error: {error}
|
||||
</div>
|
||||
) : utmData.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
No data available
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{activeTab === 'source' ? 'Source' :
|
||||
activeTab === 'medium' ? 'Medium' :
|
||||
activeTab === 'campaign' ? 'Campaign' :
|
||||
activeTab === 'term' ? 'Term' : 'Content'}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Clicks
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Visitors
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Avg. Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Bounce Rate
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Conversions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{utmData.map((item, index) => {
|
||||
const bounceRate = item.clicks > 0 ? (item.bounces / item.clicks) * 100 : 0;
|
||||
const conversionRate = item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0;
|
||||
|
||||
return (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.utm_value || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatNumber(item.clicks)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatNumber(item.visitors)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.avg_time_spent.toFixed(1)}s
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{bounceRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatNumber(item.conversions)} ({conversionRate.toFixed(1)}%)
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
189
app/components/charts/TimeSeriesChart.tsx
Normal file
189
app/components/charts/TimeSeriesChart.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ChartData,
|
||||
ChartOptions,
|
||||
TooltipItem
|
||||
} from 'chart.js';
|
||||
import { TimeSeriesData } from '@/app/api/types';
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface TimeSeriesChartProps {
|
||||
data: TimeSeriesData[];
|
||||
}
|
||||
|
||||
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
||||
const chartRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartInstance = useRef<ChartJS | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
// 销毁旧的图表实例
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = chartRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 准备数据
|
||||
const labels = data.map(item => {
|
||||
if (!item || !item.timestamp) return '';
|
||||
const date = new Date(item.timestamp);
|
||||
return date.toLocaleDateString();
|
||||
});
|
||||
|
||||
const eventsData = data.map(item => {
|
||||
if (!item || item.events === undefined || item.events === null) return 0;
|
||||
return Number(item.events);
|
||||
});
|
||||
|
||||
const visitorsData = data.map(item => {
|
||||
if (!item || item.visitors === undefined || item.visitors === null) return 0;
|
||||
return Number(item.visitors);
|
||||
});
|
||||
|
||||
const conversionsData = data.map(item => {
|
||||
if (!item || item.conversions === undefined || item.conversions === null) return 0;
|
||||
return Number(item.conversions);
|
||||
});
|
||||
|
||||
// 创建新的图表实例
|
||||
chartInstance.current = new ChartJS(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Events',
|
||||
data: eventsData,
|
||||
borderColor: 'rgb(59, 130, 246)', // blue-500
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Visitors',
|
||||
data: visitorsData,
|
||||
borderColor: 'rgb(16, 185, 129)', // green-500
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Conversions',
|
||||
data: conversionsData,
|
||||
borderColor: 'rgb(239, 68, 68)', // red-500
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
} as ChartData<'line'>,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
color: 'rgb(156, 163, 175)' // gray-400
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'rgb(31, 41, 55)', // gray-800
|
||||
titleColor: 'rgb(229, 231, 235)', // gray-200
|
||||
bodyColor: 'rgb(229, 231, 235)', // gray-200
|
||||
borderColor: 'rgb(75, 85, 99)', // gray-600
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<'line'>[]) => {
|
||||
if (items.length > 0) {
|
||||
const date = new Date(data[items[0].dataIndex].timestamp);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
return '';
|
||||
},
|
||||
label: (context) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
return `${label}: ${Math.round(value)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)' // gray-400
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(156, 163, 175)', // gray-400
|
||||
callback: (value: number) => {
|
||||
if (!value && value !== 0) return '';
|
||||
if (value >= 1000) {
|
||||
return `${Math.round(value / 1000)}k`;
|
||||
}
|
||||
return Math.round(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as ChartOptions<'line'>
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<canvas ref={chartRef} />
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
73
app/components/layout/Header.tsx
Normal file
73
app/components/layout/Header.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export default function Header() {
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="w-full py-4 border-b border-gray-200 bg-white">
|
||||
<div className="container flex items-center justify-between px-4 mx-auto">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/analytics" className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
<span className="text-xl font-bold text-gray-900">ShortURL Analytics</span>
|
||||
</Link>
|
||||
|
||||
{user && (
|
||||
<nav className="ml-6">
|
||||
<ul className="flex space-x-4">
|
||||
<li>
|
||||
<Link href="/analytics" className="text-sm text-gray-700 hover:text-blue-500">
|
||||
Analytics
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/links" className="text-sm text-gray-700 hover:text-blue-500">
|
||||
Short Links
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/create-shorturl" className="text-sm text-gray-700 hover:text-blue-500">
|
||||
Create Short URL
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
{user.email}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-500 rounded hover:bg-blue-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import ThemeToggle from "../ui/ThemeToggle";
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<header className="w-full py-4 border-b border-card-border bg-background">
|
||||
<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-accent-blue"
|
||||
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-foreground">ShortURL</span>
|
||||
</Link>
|
||||
<nav className="hidden space-x-4 md:flex">
|
||||
<Link
|
||||
href="/links"
|
||||
className="text-sm text-foreground hover:text-accent-blue transition-colors"
|
||||
>
|
||||
Links
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="text-sm text-foreground hover:text-accent-blue transition-colors"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
</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>
|
||||
<button className="p-2 text-sm text-foreground hover:text-accent-blue">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
<path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
267
app/components/ui/TeamSelector.tsx
Normal file
267
app/components/ui/TeamSelector.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"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';
|
||||
import { limqRequest } from '@/lib/api';
|
||||
|
||||
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();
|
||||
|
||||
// 尝试创建默认团队和项目(如果用户还没有)
|
||||
try {
|
||||
const response = await limqRequest('team/create-default', 'POST');
|
||||
console.log('Default team creation response:', response);
|
||||
} catch (teamError) {
|
||||
console.error('Error creating default team:', teamError);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
403
app/create-shorturl/page.tsx
Normal file
403
app/create-shorturl/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { ProtectedRoute } from '@/lib/auth';
|
||||
import { limqRequest } from '@/lib/api';
|
||||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||||
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
|
||||
|
||||
interface ShortUrlData {
|
||||
originalUrl: string;
|
||||
customSlug?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export default function CreateShortUrlPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<CreateShortUrlForm />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateShortUrlForm() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState<ShortUrlData>({
|
||||
originalUrl: '',
|
||||
customSlug: '',
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
teamId: '',
|
||||
projectId: '',
|
||||
domain: 'googleads.link',
|
||||
});
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Use useEffect to add user information to form data on load
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
console.log('Current user:', user.email);
|
||||
// Can add user-related data to the form here
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tagInput.trim()]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags?.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!formData.originalUrl) {
|
||||
throw new Error('Original URL is required');
|
||||
}
|
||||
|
||||
if (!formData.title) {
|
||||
throw new Error('Title is required');
|
||||
}
|
||||
|
||||
if (!formData.teamId) {
|
||||
throw new Error('Team is required');
|
||||
}
|
||||
|
||||
if (!formData.projectId) {
|
||||
throw new Error('Project is required');
|
||||
}
|
||||
|
||||
if (!formData.domain) {
|
||||
throw new Error('Domain is required');
|
||||
}
|
||||
|
||||
// Construct request data according to API requirements
|
||||
const requestData = {
|
||||
type: "shorturl",
|
||||
attributes: {
|
||||
// Can add any additional attributes, but attributes cannot be empty
|
||||
icon: ""
|
||||
},
|
||||
shortUrl: {
|
||||
url: formData.originalUrl,
|
||||
slug: formData.customSlug || undefined,
|
||||
title: formData.title,
|
||||
name: formData.title,
|
||||
description: formData.description || "",
|
||||
domain: formData.domain
|
||||
},
|
||||
teamId: formData.teamId,
|
||||
projectId: formData.projectId,
|
||||
tagIds: formData.tags && formData.tags.length > 0 ? formData.tags : undefined
|
||||
};
|
||||
|
||||
// Call API to create shorturl resource
|
||||
const response = await limqRequest('resource/shorturl', 'POST', requestData as unknown as Record<string, unknown>);
|
||||
|
||||
console.log('Created successfully:', response);
|
||||
setSuccess(true);
|
||||
|
||||
// Redirect to links list page after 2 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/links');
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to create short URL:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create short URL, please try again later');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="border-b border-gray-200 bg-blue-50 px-6 py-4">
|
||||
<h1 className="text-xl font-medium text-gray-900">Create Short URL</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Create a new short URL resource for tracking and analytics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 m-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 border-l-4 border-green-500 p-4 m-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">
|
||||
Short URL created successfully! Redirecting...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Product Launch Campaign"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Original URL */}
|
||||
<div>
|
||||
<label htmlFor="originalUrl" className="block text-sm font-medium text-gray-700">
|
||||
Original URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="originalUrl"
|
||||
name="originalUrl"
|
||||
value={formData.originalUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com/your-long-url"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Short Link */}
|
||||
<div>
|
||||
<label htmlFor="customSlug" className="block text-sm font-medium text-gray-700">
|
||||
Custom Short Link <span className="text-gray-500">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 py-2 text-sm text-gray-500 border border-r-0 border-gray-300 rounded-l-md bg-gray-50">
|
||||
{formData.domain}/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="customSlug"
|
||||
name="customSlug"
|
||||
value={formData.customSlug}
|
||||
onChange={handleChange}
|
||||
placeholder="custom-slug"
|
||||
className="flex-1 block w-full min-w-0 px-3 py-2 border border-gray-300 rounded-none rounded-r-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Leave blank to generate a random short link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div>
|
||||
<label htmlFor="domain" className="block text-sm font-medium text-gray-700">
|
||||
Domain <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="domain"
|
||||
name="domain"
|
||||
value={formData.domain}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., googleads.link"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
<div>
|
||||
<label htmlFor="teamId" className="block text-sm font-medium text-gray-700">
|
||||
Team <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TeamSelector
|
||||
value={formData.teamId}
|
||||
onChange={(teamId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
teamId: teamId as string,
|
||||
// Clear selected project when team changes
|
||||
projectId: ''
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Selection */}
|
||||
<div>
|
||||
<label htmlFor="projectId" className="block text-sm font-medium text-gray-700">
|
||||
Project <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<ProjectSelector
|
||||
teamId={formData.teamId}
|
||||
value={formData.projectId}
|
||||
onChange={(projectId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
projectId: projectId as string
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Description <span className="text-gray-500">(Optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="A brief description of this link"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label htmlFor="tagInput" className="block text-sm font-medium text-gray-700">
|
||||
Tags <span className="text-gray-500">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="tagInput"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
placeholder="Add a tag and press Enter"
|
||||
className="flex-1 block w-full min-w-0 px-3 py-2 border border-gray-300 rounded-l-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white border border-transparent rounded-r-md shadow-sm bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.tags.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-100 rounded-full text-blue-800">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="flex-shrink-0 ml-1 text-blue-500 rounded-full hover:text-blue-700 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Remove tag {tag}</span>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mr-3"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2 -ml-1 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</>
|
||||
) : 'Create Short URL'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import './globals.css';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Navbar from "./components/layout/Navbar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { Theme } from '@radix-ui/themes';
|
||||
import Header from '@/app/components/layout/Header';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ShortURL Analytics',
|
||||
description: 'Analytics dashboard for short URL management',
|
||||
description: 'Track and analyze shortened links',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,13 +17,13 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
|
||||
>
|
||||
<Navbar />
|
||||
<main className="min-h-screen px-4 py-6">
|
||||
<body>
|
||||
<Theme>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
{children}
|
||||
</main>
|
||||
</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
1036
app/links/page.tsx
1036
app/links/page.tsx
File diff suppressed because it is too large
Load Diff
207
app/login/page.tsx
Normal file
207
app/login/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
// Separate component for message handling to isolate useSearchParams
|
||||
function MessageHandler({ setMessage }: { setMessage: (message: { type: string, content: string }) => void }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const messageParam = searchParams.get('message');
|
||||
if (messageParam) {
|
||||
setMessage({ type: 'info', content: messageParam });
|
||||
}
|
||||
}, [searchParams, setMessage]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { signIn, 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 handleGoogleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', content: '' });
|
||||
|
||||
const { error } = await signInWithGoogle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Google OAuth will handle the redirect
|
||||
} 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">
|
||||
{/* Wrap the component using useSearchParams in Suspense */}
|
||||
<Suspense fallback={null}>
|
||||
<MessageHandler setMessage={setMessage} />
|
||||
</Suspense>
|
||||
|
||||
<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 rounded-lg ${
|
||||
message.type === 'error'
|
||||
? 'text-red-700 bg-red-100 border border-red-200'
|
||||
: 'text-blue-700 bg-blue-50 border border-blue-200'
|
||||
}`}>
|
||||
{message.type === 'error' ? (
|
||||
<span className="font-medium">Error: </span>
|
||||
) : (
|
||||
<span className="font-medium">Notice: </span>
|
||||
)}
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Sign In Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-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" 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>
|
||||
{isLoading ? 'Signing in...' : 'Sign in with Google'}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
<div className="border-t border-gray-300 flex-grow mr-3"></div>
|
||||
<span className="text-sm text-gray-500">or</span>
|
||||
<div className="border-t border-gray-300 flex-grow ml-3"></div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEmailSignIn} className="mt-6 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 with Email'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="text-sm mt-6 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/links');
|
||||
redirect('/analytics');
|
||||
}
|
||||
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();
|
||||
|
||||
// Handle registration form submission
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate passwords
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
// Password strength validation
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signUp(email, password);
|
||||
// After successful registration, redirect to login page with email verification prompt
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
setError('Registration failed. Please try again later or use a different email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Google registration/login
|
||||
const handleGoogleSignIn = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
// Login flow will redirect to Google and then back to the application
|
||||
} catch (error) {
|
||||
console.error('Google sign in error:', error);
|
||||
setError('Google login failed. Please try again later');
|
||||
}
|
||||
};
|
||||
|
||||
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">Register</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Create your account to access the analytics dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{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">
|
||||
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 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">
|
||||
Password
|
||||
</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">
|
||||
Confirm Password
|
||||
</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 ? 'Registering...' : 'Register'}
|
||||
</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">or</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>
|
||||
Sign up with Google
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Log in
|
||||
</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';
|
||||
}
|
||||
}
|
||||
52
app/utils/store.ts
Normal file
52
app/utils/store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Define interface for team, project and tag objects
|
||||
interface TeamData {
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 定义 ShortUrl 数据类型
|
||||
export interface ShortUrlData {
|
||||
id: string;
|
||||
externalId: string;
|
||||
slug: string;
|
||||
originalUrl: string;
|
||||
title?: string;
|
||||
shortUrl: string;
|
||||
teams?: TeamData[];
|
||||
projects?: ProjectData[];
|
||||
tags?: string[];
|
||||
createdAt?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
// 定义 store 类型
|
||||
interface ShortUrlStore {
|
||||
selectedShortUrl: ShortUrlData | null;
|
||||
setSelectedShortUrl: (shortUrl: ShortUrlData | null) => void;
|
||||
clearSelectedShortUrl: () => void;
|
||||
}
|
||||
|
||||
// 创建 store 并使用 persist 中间件保存到 localStorage
|
||||
export const useShortUrlStore = create<ShortUrlStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedShortUrl: null,
|
||||
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
|
||||
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
|
||||
}),
|
||||
{
|
||||
name: 'shorturl-storage', // localStorage 中的 key 名称
|
||||
partialize: (state) => ({ selectedShortUrl: state.selectedShortUrl }), // 只持久化 selectedShortUrl
|
||||
}
|
||||
)
|
||||
);
|
||||
59
app/utils/supabase.ts
Normal file
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
|
||||
1502
lib/analytics.ts
1502
lib/analytics.ts
File diff suppressed because it is too large
Load Diff
50
lib/api.ts
Normal file
50
lib/api.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import supabase from './supabase';
|
||||
|
||||
// Define response type for API
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Common function for authenticated API requests to LIMQ
|
||||
export async function limqRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
data?: Record<string, unknown>
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Get current session
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
throw new Error('No active session. User must be authenticated.');
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_LIMQ_API;
|
||||
const url = `${baseUrl}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`;
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.access_token}`
|
||||
},
|
||||
mode: 'cors'
|
||||
};
|
||||
|
||||
if (data && (method === 'POST' || method === 'PUT')) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.error || `Request failed with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
257
lib/auth.tsx
Normal file
257
lib/auth.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'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';
|
||||
import { limqRequest } from './api';
|
||||
|
||||
// 定义用户类型
|
||||
export type AuthUser = User | null;
|
||||
|
||||
// 定义验证上下文类型
|
||||
export type AuthContextType = {
|
||||
user: AuthUser;
|
||||
session: Session | null;
|
||||
isLoading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: unknown }>;
|
||||
signInWithGoogle: () => Promise<{ error?: unknown }>;
|
||||
signInWithGitHub: () => Promise<{ error?: unknown }>;
|
||||
signUp: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
};
|
||||
|
||||
// 创建验证上下文
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// 验证提供者组件
|
||||
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('/analytics');
|
||||
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,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('注册出错:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 注册成功后跳转到登录页面并显示确认消息
|
||||
router.push('/login?message=Registration successful! Please check your email to verify your account before logging in.');
|
||||
} 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 contextValue: AuthContextType = {
|
||||
user,
|
||||
session,
|
||||
isLoading,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
signInWithGitHub,
|
||||
signUp,
|
||||
signOut,
|
||||
};
|
||||
|
||||
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,165 @@
|
||||
import { createClient } from '@clickhouse/client';
|
||||
import { EventsQueryParams } from './analytics';
|
||||
|
||||
// Create configuration object using the URL approach
|
||||
const config = {
|
||||
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
||||
username: process.env.CLICKHOUSE_USER || 'default',
|
||||
password: process.env.CLICKHOUSE_PASSWORD || '',
|
||||
database: process.env.CLICKHOUSE_DATABASE || 'limq'
|
||||
};
|
||||
// ClickHouse 客户端配置
|
||||
const clickhouse = createClient({
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
username: process.env.CLICKHOUSE_USER ,
|
||||
password: process.env.CLICKHOUSE_PASSWORD ,
|
||||
database: process.env.CLICKHOUSE_DATABASE
|
||||
});
|
||||
|
||||
// Create ClickHouse client with proper URL format
|
||||
export const clickhouse = createClient(config);
|
||||
// 构建日期过滤条件
|
||||
function buildDateFilter(startTime?: string, endTime?: string): string {
|
||||
const filters = [];
|
||||
|
||||
if (startTime) {
|
||||
filters.push(`event_time >= parseDateTimeBestEffort('${startTime}')`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return results
|
||||
*/
|
||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
||||
if (endTime) {
|
||||
filters.push(`event_time <= parseDateTimeBestEffort('${endTime}')`);
|
||||
}
|
||||
|
||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
}
|
||||
|
||||
// 构建通用过滤条件
|
||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||
console.log('buildFilter received params:', JSON.stringify(params));
|
||||
const filters = [];
|
||||
|
||||
// 添加日期过滤条件
|
||||
if (params.startTime || params.endTime) {
|
||||
const dateFilter = buildDateFilter(params.startTime, params.endTime);
|
||||
if (dateFilter) {
|
||||
filters.push(dateFilter.replace('WHERE ', ''));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加事件类型过滤条件
|
||||
if (params.eventType) {
|
||||
filters.push(`event_type = '${params.eventType}'`);
|
||||
}
|
||||
|
||||
// 添加链接ID过滤条件
|
||||
if (params.linkId) {
|
||||
console.log('Adding link_id filter:', params.linkId);
|
||||
filters.push(`link_id = '${params.linkId}'`);
|
||||
}
|
||||
|
||||
// 添加链接Slug过滤条件
|
||||
if (params.linkSlug) {
|
||||
filters.push(`link_slug = '${params.linkSlug}'`);
|
||||
}
|
||||
|
||||
// 添加用户ID过滤条件
|
||||
if (params.userId) {
|
||||
filters.push(`user_id = '${params.userId}'`);
|
||||
}
|
||||
|
||||
// 添加子路径过滤条件 - 使用更精确的匹配方式
|
||||
if (params.subpath && params.subpath.trim() !== '') {
|
||||
console.log('====== SUBPATH DEBUG ======');
|
||||
console.log('Raw subpath param:', params.subpath);
|
||||
|
||||
// 清理并准备subpath值
|
||||
let cleanSubpath = params.subpath.trim();
|
||||
// 移除开头的斜杠以便匹配
|
||||
if (cleanSubpath.startsWith('/')) {
|
||||
cleanSubpath = cleanSubpath.substring(1);
|
||||
}
|
||||
// 移除结尾的斜杠以便匹配
|
||||
if (cleanSubpath.endsWith('/')) {
|
||||
cleanSubpath = cleanSubpath.substring(0, cleanSubpath.length - 1);
|
||||
}
|
||||
|
||||
console.log('Cleaned subpath:', cleanSubpath);
|
||||
|
||||
// 使用正则表达式匹配URL中的第二个路径部分
|
||||
// 示例: 在 "https://abc.com/slug/subpath/" 中匹配 "subpath"
|
||||
const condition = `match(JSONExtractString(event_attributes, 'full_url'), '/[^/]+/${cleanSubpath}(/|\\\\?|$)')`;
|
||||
|
||||
console.log('Final SQL condition:', condition);
|
||||
console.log('==========================');
|
||||
|
||||
filters.push(condition);
|
||||
}
|
||||
|
||||
// 添加团队ID过滤条件
|
||||
if (params.teamId) {
|
||||
filters.push(`team_id = '${params.teamId}'`);
|
||||
}
|
||||
|
||||
// 处理多个团队ID
|
||||
if (params.teamIds && params.teamIds.length > 0) {
|
||||
filters.push(`team_id IN (${params.teamIds.map(id => `'${id}'`).join(', ')})`);
|
||||
}
|
||||
|
||||
// 添加项目ID过滤条件
|
||||
if (params.projectId) {
|
||||
filters.push(`project_id = '${params.projectId}'`);
|
||||
}
|
||||
|
||||
// 处理多个项目ID
|
||||
if (params.projectIds && params.projectIds.length > 0) {
|
||||
filters.push(`project_id IN (${params.projectIds.map(id => `'${id}'`).join(', ')})`);
|
||||
}
|
||||
|
||||
// 处理标签过滤 - 使用LIKE来匹配标签字符串
|
||||
if (params.tagIds && params.tagIds.length > 0) {
|
||||
const tagConditions = params.tagIds.map(tag =>
|
||||
`link_tags LIKE '%${tag}%'`
|
||||
);
|
||||
filters.push(`(${tagConditions.join(' OR ')})`);
|
||||
}
|
||||
|
||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
}
|
||||
|
||||
// 构建分页条件
|
||||
export function buildPagination(page: number = 1, pageSize: number = 20): string {
|
||||
const offset = (page - 1) * pageSize;
|
||||
return `LIMIT ${pageSize} OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
// 构建排序条件
|
||||
export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
|
||||
return `ORDER BY ${sortBy} ${sortOrder}`;
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
export async function executeQuery(query: string) {
|
||||
console.log('Executing query:', query); // 查询日志
|
||||
try {
|
||||
const result = await clickhouse.query({
|
||||
const resultSet = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const data = await result.json();
|
||||
return data as T[];
|
||||
const rows = await resultSet.json();
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('ClickHouse query error:', error);
|
||||
console.error('查询执行错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return a single result
|
||||
*/
|
||||
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
||||
const results = await executeQuery<T>(query);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
// 执行返回单一结果的查询
|
||||
export async function executeQuerySingle(query: string) {
|
||||
console.log('Executing single result query:', query); // 查询日志
|
||||
try {
|
||||
const resultSet = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = await resultSet.json();
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
} catch (error) {
|
||||
console.error('单一结果查询执行错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default clickhouse;
|
||||
65
lib/supabase.ts
Normal file
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));
|
||||
}
|
||||
22
middleware.ts
Normal file
22
middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const res = NextResponse.next();
|
||||
|
||||
// Create a Supabase client configured to use cookies
|
||||
const supabase = createMiddlewareClient({ req, res });
|
||||
|
||||
// Refresh session if expired - required for Server Components
|
||||
await supabase.auth.getSession();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Specify the paths where this middleware should run
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
23
package.json
23
package.json
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev -p 3007",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -24,20 +24,39 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/recharts": "^1.8.29",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"chart.js": "^4.4.8",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "15.2.3",
|
||||
"process": "^0.11.10",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
"recharts": "^2.15.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
|
||||
2799
pnpm-lock.yaml
generated
2799
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
|
||||
获取所有表...
|
||||
数据库 limq 中找到以下表:
|
||||
- .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
|
||||
- .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
|
||||
- .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
|
||||
- .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
|
||||
- .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
|
||||
- .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
|
||||
- link_daily_stats
|
||||
- link_events
|
||||
- link_hourly_patterns
|
||||
- links
|
||||
- platform_distribution
|
||||
- project_daily_stats
|
||||
- projects
|
||||
- qr_scans
|
||||
- qrcode_daily_stats
|
||||
- qrcodes
|
||||
- sessions
|
||||
- team_daily_stats
|
||||
- team_members
|
||||
- teams
|
||||
|
||||
所有ClickHouse表:
|
||||
.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
|
||||
|
||||
获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
|
||||
|
||||
获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
|
||||
|
||||
获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
|
||||
|
||||
获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
|
||||
|
||||
获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
|
||||
|
||||
获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
|
||||
|
||||
获取表 link_daily_stats 的结构...
|
||||
表 link_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- unique_sessions (UInt64, 无默认值)
|
||||
- total_time_spent (UInt64, 无默认值)
|
||||
- avg_time_spent (Float64, 无默认值)
|
||||
- bounce_count (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- unique_referrers (UInt64, 无默认值)
|
||||
- mobile_count (UInt64, 无默认值)
|
||||
- tablet_count (UInt64, 无默认值)
|
||||
- desktop_count (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
- total_conversion_value (Float64, 无默认值)
|
||||
|
||||
获取表 link_events 的结构...
|
||||
表 link_events 的列:
|
||||
- event_id (UUID, 默认值: generateUUIDv4())
|
||||
- event_time (DateTime64(3), 默认值: now64())
|
||||
- date (Date, 默认值: toDate(event_time))
|
||||
- link_id (String, 无默认值)
|
||||
- channel_id (String, 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- session_id (String, 无默认值)
|
||||
- event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
|
||||
- ip_address (String, 无默认值)
|
||||
- country (String, 无默认值)
|
||||
- city (String, 无默认值)
|
||||
- referrer (String, 无默认值)
|
||||
- utm_source (String, 无默认值)
|
||||
- utm_medium (String, 无默认值)
|
||||
- utm_campaign (String, 无默认值)
|
||||
- user_agent (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- browser (String, 无默认值)
|
||||
- os (String, 无默认值)
|
||||
- time_spent_sec (UInt32, 默认值: 0)
|
||||
- is_bounce (Bool, 默认值: true)
|
||||
- is_qr_scan (Bool, 默认值: false)
|
||||
- qr_code_id (String, 默认值: '')
|
||||
- conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
|
||||
- conversion_value (Float64, 默认值: 0)
|
||||
- custom_data (String, 默认值: '{}')
|
||||
|
||||
获取表 link_hourly_patterns 的结构...
|
||||
表 link_hourly_patterns 的列:
|
||||
- date (Date, 无默认值)
|
||||
- hour (UInt8, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- visits (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
|
||||
获取表 links 的结构...
|
||||
表 links 的列:
|
||||
- link_id (String, 无默认值)
|
||||
- original_url (String, 无默认值)
|
||||
- created_at (DateTime64(3), 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- title (String, 无默认值)
|
||||
- description (String, 无默认值)
|
||||
- tags (Array(String), 无默认值)
|
||||
- is_active (Bool, 默认值: true)
|
||||
- expires_at (Nullable(DateTime), 无默认值)
|
||||
- team_id (String, 默认值: '')
|
||||
- project_id (String, 默认值: '')
|
||||
|
||||
获取表 platform_distribution 的结构...
|
||||
表 platform_distribution 的列:
|
||||
- date (Date, 无默认值)
|
||||
- utm_source (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- visits (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
|
||||
获取表 project_daily_stats 的结构...
|
||||
表 project_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- project_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- links_used (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
|
||||
获取表 projects 的结构...
|
||||
表 projects 的列:
|
||||
- project_id (String, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- name (String, 无默认值)
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- is_archived (Bool, 默认值: false)
|
||||
- links_count (UInt32, 默认值: 0)
|
||||
- total_clicks (UInt64, 默认值: 0)
|
||||
- last_updated (DateTime, 默认值: now())
|
||||
|
||||
获取表 qr_scans 的结构...
|
||||
表 qr_scans 的列:
|
||||
- scan_id (UUID, 默认值: generateUUIDv4())
|
||||
- qr_code_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- scan_time (DateTime64(3), 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- location (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- led_to_conversion (Bool, 默认值: false)
|
||||
|
||||
获取表 qrcode_daily_stats 的结构...
|
||||
表 qrcode_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- qr_code_id (String, 无默认值)
|
||||
- total_scans (UInt64, 无默认值)
|
||||
- unique_scanners (UInt64, 无默认值)
|
||||
- conversions (UInt64, 无默认值)
|
||||
- mobile_scans (UInt64, 无默认值)
|
||||
- tablet_scans (UInt64, 无默认值)
|
||||
- desktop_scans (UInt64, 无默认值)
|
||||
- unique_locations (UInt64, 无默认值)
|
||||
|
||||
获取表 qrcodes 的结构...
|
||||
表 qrcodes 的列:
|
||||
- qr_code_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- project_id (String, 默认值: '')
|
||||
- name (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- updated_at (DateTime, 默认值: now())
|
||||
- qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
|
||||
- image_url (String, 默认值: '')
|
||||
- design_config (String, 默认值: '{}')
|
||||
- is_active (Bool, 默认值: true)
|
||||
- total_scans (UInt64, 默认值: 0)
|
||||
- unique_scanners (UInt32, 默认值: 0)
|
||||
|
||||
获取表 sessions 的结构...
|
||||
表 sessions 的列:
|
||||
- session_id (String, 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- started_at (DateTime64(3), 无默认值)
|
||||
- last_activity (DateTime64(3), 无默认值)
|
||||
- ended_at (Nullable(DateTime64(3)), 无默认值)
|
||||
- duration_sec (UInt32, 默认值: 0)
|
||||
- session_pages (UInt8, 默认值: 1)
|
||||
- is_completed (Bool, 默认值: false)
|
||||
|
||||
获取表 team_daily_stats 的结构...
|
||||
表 team_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- links_used (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
|
||||
获取表 team_members 的结构...
|
||||
表 team_members 的列:
|
||||
- team_id (String, 无默认值)
|
||||
- user_id (String, 无默认值)
|
||||
- role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
|
||||
- joined_at (DateTime, 默认值: now())
|
||||
- invited_by (String, 无默认值)
|
||||
- is_active (Bool, 默认值: true)
|
||||
- last_active (DateTime, 默认值: now())
|
||||
|
||||
获取表 teams 的结构...
|
||||
表 teams 的列:
|
||||
- team_id (String, 无默认值)
|
||||
- name (String, 无默认值)
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- avatar_url (String, 默认值: '')
|
||||
- is_active (Bool, 默认值: true)
|
||||
- plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
|
||||
- members_count (UInt32, 默认值: 1)
|
||||
|
||||
ClickHouse数据库结构检查完成
|
||||
5
scripts/db/sql/clickhouse/add_domain_column.sql
Normal file
5
scripts/db/sql/clickhouse/add_domain_column.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 添加domain列到shorturl_analytics.shorturl表
|
||||
ALTER TABLE
|
||||
shorturl_analytics.shorturl
|
||||
ADD
|
||||
COLUMN IF NOT EXISTS domain Nullable(String) COMMENT '域名';
|
||||
9
scripts/db/sql/clickhouse/add_req_full_path.sql
Normal file
9
scripts/db/sql/clickhouse/add_req_full_path.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- add_req_full_path.sql
|
||||
-- Add req_full_path column to the shorturl_analytics.events table
|
||||
ALTER TABLE
|
||||
shorturl_analytics.events
|
||||
ADD
|
||||
COLUMN IF NOT EXISTS req_full_path String COMMENT 'Full request path including query parameters';
|
||||
|
||||
-- Display the updated table structure
|
||||
DESCRIBE TABLE shorturl_analytics.events;
|
||||
41
scripts/db/sql/clickhouse/add_utm_fields.sql
Normal file
41
scripts/db/sql/clickhouse/add_utm_fields.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- 添加缺失的UTM参数字段到shorturl_analytics.events表
|
||||
-- 创建日期: 2024-07-02
|
||||
-- 用途: 增强UTM参数追踪能力
|
||||
-- 添加utm_term字段 (用于跟踪付费搜索关键词)
|
||||
ALTER TABLE
|
||||
shorturl_analytics.events
|
||||
ADD
|
||||
COLUMN utm_term String DEFAULT '' AFTER utm_campaign;
|
||||
|
||||
-- 添加utm_content字段 (用于区分相同广告的不同版本或A/B测试)
|
||||
ALTER TABLE
|
||||
shorturl_analytics.events
|
||||
ADD
|
||||
COLUMN utm_content String DEFAULT '' AFTER utm_term;
|
||||
|
||||
-- 验证字段添加成功
|
||||
DESCRIBE TABLE shorturl_analytics.events;
|
||||
|
||||
-- 示例查询: 查看UTM参数分析数据
|
||||
SELECT
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
utm_term,
|
||||
utm_content,
|
||||
COUNT(*) as clicks
|
||||
FROM
|
||||
shorturl_analytics.events
|
||||
WHERE
|
||||
event_type = 'click'
|
||||
AND utm_source != ''
|
||||
GROUP BY
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
utm_term,
|
||||
utm_content
|
||||
ORDER BY
|
||||
clicks DESC
|
||||
LIMIT
|
||||
10;
|
||||
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;
|
||||
46
scripts/db/sql/clickhouse/create_shorturl_table.sql
Normal file
46
scripts/db/sql/clickhouse/create_shorturl_table.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- 使用shorturl_analytics数据库
|
||||
USE shorturl_analytics;
|
||||
|
||||
-- 删除已存在的shorturl表
|
||||
DROP TABLE IF EXISTS shorturl_analytics.shorturl;
|
||||
|
||||
-- 创建shorturl表
|
||||
CREATE TABLE IF NOT EXISTS shorturl_analytics.shorturl (
|
||||
-- 短链接基本信息(来源于resources表)
|
||||
id String COMMENT '资源ID (resources.id)',
|
||||
external_id String COMMENT '外部ID (resources.external_id)',
|
||||
type String COMMENT '类型,值为shorturl',
|
||||
slug String COMMENT '短链接slug (存储在attributes中)',
|
||||
original_url String COMMENT '原始URL (存储在attributes中)',
|
||||
title String COMMENT '标题 (存储在attributes中)',
|
||||
description String COMMENT '描述 (存储在attributes中)',
|
||||
attributes String DEFAULT '{}' COMMENT '资源属性JSON',
|
||||
schema_version Int32 COMMENT 'Schema版本',
|
||||
-- 创建者信息
|
||||
creator_id String COMMENT '创建者ID (resources.creator_id)',
|
||||
creator_email String COMMENT '创建者邮箱 (来自users表)',
|
||||
creator_name String COMMENT '创建者名称 (来自users表)',
|
||||
-- 时间信息
|
||||
created_at DateTime64(3) COMMENT '创建时间 (resources.created_at)',
|
||||
updated_at DateTime64(3) COMMENT '更新时间 (resources.updated_at)',
|
||||
deleted_at Nullable(DateTime64(3)) COMMENT '删除时间 (resources.deleted_at)',
|
||||
-- 项目关联 (project_resources表)
|
||||
projects String DEFAULT '[]' COMMENT '项目关联信息数组。结构: [{project_id: String, project_name: String, project_description: String, assigned_at: DateTime64}]',
|
||||
-- 团队关联 (通过项目关联到团队)
|
||||
teams String DEFAULT '[]' COMMENT '团队关联信息数组。结构: [{team_id: String, team_name: String, team_description: String, via_project_id: String}]',
|
||||
-- 标签关联 (resource_tags表)
|
||||
tags String DEFAULT '[]' COMMENT '标签关联信息数组。结构: [{tag_id: String, tag_name: String, tag_type: String, created_at: DateTime64}]',
|
||||
-- QR码关联 (qr_code表)
|
||||
qr_codes String DEFAULT '[]' COMMENT 'QR码信息数组。结构: [{qr_id: String, scan_count: Int32, url: String, template_name: String, created_at: DateTime64}]',
|
||||
-- 渠道关联 (channel表)
|
||||
channels String DEFAULT '[]' COMMENT '渠道信息数组。结构: [{channel_id: String, channel_name: String, channel_path: String, is_user_created: Boolean}]',
|
||||
-- 收藏关联 (favorite表)
|
||||
favorites String DEFAULT '[]' COMMENT '收藏信息数组。结构: [{favorite_id: String, user_id: String, user_name: String, created_at: DateTime64}]',
|
||||
-- 自定义过期时间 (存储在attributes中)
|
||||
expires_at Nullable(DateTime64(3)) COMMENT '过期时间',
|
||||
-- 统计信息 (分析时聚合计算)
|
||||
click_count UInt32 DEFAULT 0 COMMENT '点击次数',
|
||||
unique_visitors UInt32 DEFAULT 0 COMMENT '唯一访问者数'
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY
|
||||
(id, created_at) SETTINGS index_granularity = 8192 COMMENT '用于存储所有shorturl类型资源的统一表,集成了相关联的项目、团队、标签、QR码、渠道和收藏信息';
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user