events filter

This commit is contained in:
2025-04-02 08:55:46 +08:00
parent 4b7fb7a887
commit 9cb9f62686
7 changed files with 185 additions and 101 deletions

View File

@@ -112,9 +112,9 @@ export default function AnalyticsPage() {
Tag Analytics ({selectedTagIds.length} selected) Tag Analytics ({selectedTagIds.length} selected)
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedTagIds.map((tagId) => ( {selectedTagIds.map((tagName) => (
<div key={tagId} className="p-4 border rounded-md"> <div key={tagName} className="p-4 border rounded-md">
<h3 className="font-medium text-gray-800">Tag ID: {tagId}</h3> <h3 className="font-medium text-gray-800">Tag: {tagName}</h3>
<p className="text-gray-500 mt-2">Tag analytics will appear here</p> <p className="text-gray-500 mt-2">Tag analytics will appear here</p>
</div> </div>
))} ))}

View File

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

View File

@@ -46,18 +46,47 @@ export function TagSelector({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null); const selectorRef = useRef<HTMLDivElement>(null);
// Initialize selected tags based on value prop // 标签名称与ID的映射函数
useEffect(() => { const getTagIdByName = (name: string): string | undefined => {
if (value) { const tag = tags.find(t => t.name === name);
if (Array.isArray(value)) { return tag?.id;
setSelectedIds(value); };
} else {
setSelectedIds(value ? [value] : []); const getTagNameById = (id: string): string | undefined => {
} const tag = tags.find(t => t.id === id);
} else { return tag?.name;
setSelectedIds([]); };
// 从标签名称转换为标签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);
} }
}, [value]); 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 // Add click outside listener to close dropdown
useEffect(() => { useEffect(() => {
@@ -167,8 +196,10 @@ export function TagSelector({
setSelectedIds(newSelected); setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) { if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || ''); const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
} }
}; };
@@ -176,8 +207,11 @@ export function TagSelector({
e.stopPropagation(); e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== tagId); const newSelected = selectedIds.filter(id => id !== tagId);
setSelectedIds(newSelected); setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) { if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || ''); const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
} }
}; };
@@ -214,6 +248,7 @@ export function TagSelector({
); );
} }
// 根据已选择的ID筛选出已选择的标签
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id)); const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
return ( return (

View File

@@ -322,11 +322,11 @@ export default function HomePage() {
{selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'} {selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'}
</span> </span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedTagIds.map(tagId => ( {selectedTagIds.map(tagName => (
<span key={tagId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full"> <span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{tagId} {tagName}
<button <button
onClick={() => setSelectedTagIds(selectedTagIds.filter(id => id !== tagId))} onClick={() => setSelectedTagIds(selectedTagIds.filter(name => name !== tagName))}
className="ml-1 text-blue-600 hover:text-blue-800" className="ml-1 text-blue-600 hover:text-blue-800"
> >
× ×
@@ -350,7 +350,7 @@ export default function HomePage() {
<div className="p-6 border-b border-gray-200"> <div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
@@ -473,19 +473,19 @@ export default function HomePage() {
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents} {typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
</p> </p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3> <h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors} {typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
</p> </p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Total Conversions</h3> <h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions} {typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
</p> </p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3> <h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
@@ -510,8 +510,8 @@ export default function HomePage() {
<div className="bg-white rounded-lg shadow p-6 mb-8"> <div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
<GeoAnalytics data={geoData} /> <GeoAnalytics data={geoData} />
</div> </div>
</> </>
</div> </div>
); );
} }

View File

@@ -9,8 +9,8 @@ export enum TimeGranularity {
MONTH = 'month' MONTH = 'month'
} }
// 获取事件列表 // 事件查询参数类型
export async function getEvents(params: { export interface EventsQueryParams {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
eventType?: string; eventType?: string;
@@ -19,11 +19,17 @@ export async function getEvents(params: {
userId?: string; userId?: string;
teamId?: string; teamId?: string;
projectId?: string; projectId?: string;
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
}): Promise<{ events: Event[]; total: number }> { }
// 获取事件列表
export async function getEvents(params: EventsQueryParams): Promise<{ events: Event[]; total: number }> {
const filter = buildFilter(params); const filter = buildFilter(params);
const pagination = buildPagination(params.page, params.pageSize); const pagination = buildPagination(params.page, params.pageSize);
const orderBy = buildOrderBy(params.sortBy, params.sortOrder); const orderBy = buildOrderBy(params.sortBy, params.sortOrder);

View File

@@ -28,95 +28,108 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
export function buildFilter(params: Partial<EventsQueryParams>): string { export function buildFilter(params: Partial<EventsQueryParams>): string {
const filters = []; const filters = [];
// 时间范围过滤 // 添加日期过滤条件
if (params.startTime || params.endTime) { if (params.startTime || params.endTime) {
const dateFilter = buildDateFilter(params.startTime, params.endTime).replace('WHERE ', ''); const dateFilter = buildDateFilter(params.startTime, params.endTime);
if (dateFilter) { if (dateFilter) {
filters.push(dateFilter); filters.push(dateFilter.replace('WHERE ', ''));
} }
} }
// 事件类型过滤 // 添加事件类型过滤条件
if (params.eventType) { if (params.eventType) {
filters.push(`event_type = '${params.eventType}'`); filters.push(`event_type = '${params.eventType}'`);
} }
// 链接ID过滤 // 添加链接ID过滤条件
if (params.linkId) { if (params.linkId) {
filters.push(`link_id = '${params.linkId}'`); filters.push(`link_id = '${params.linkId}'`);
} }
// 链接短码过滤 // 添加链接Slug过滤条件
if (params.linkSlug) { if (params.linkSlug) {
filters.push(`link_slug = '${params.linkSlug}'`); filters.push(`link_slug = '${params.linkSlug}'`);
} }
// 用户ID过滤 // 添加用户ID过滤条件
if (params.userId) { if (params.userId) {
filters.push(`user_id = '${params.userId}'`); filters.push(`user_id = '${params.userId}'`);
} }
// 团队ID过滤 - 支持多选 // 添加团队ID过滤条件
if (params.teamIds && params.teamIds.length > 0) { if (params.teamId) {
const teamValues = params.teamIds.map(id => `'${id}'`).join(', ');
filters.push(`team_id IN (${teamValues})`);
} else if (params.teamId) {
filters.push(`team_id = '${params.teamId}'`); filters.push(`team_id = '${params.teamId}'`);
} }
// 项目ID过滤 - 支持多选 // 处理多个团队ID
if (params.projectIds && params.projectIds.length > 0) { if (params.teamIds && params.teamIds.length > 0) {
const projectValues = params.projectIds.map(id => `'${id}'`).join(', '); filters.push(`team_id IN (${params.teamIds.map(id => `'${id}'`).join(', ')})`);
filters.push(`project_id IN (${projectValues})`); }
} else if (params.projectId) {
// 添加项目ID过滤条件
if (params.projectId) {
filters.push(`project_id = '${params.projectId}'`); filters.push(`project_id = '${params.projectId}'`);
} }
// 标签ID过滤 - 支持多选 // 处理多个项目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) { if (params.tagIds && params.tagIds.length > 0) {
// 假设我们在link_tags字段存储标签ID的JSON数组 const tagConditions = params.tagIds.map(tag =>
const tagConditions = params.tagIds.map(id => `arrayExists(x -> x = '${id}', JSONExtractArrayRaw(link_tags))`); `link_tags LIKE '%${tag}%'`
);
filters.push(`(${tagConditions.join(' OR ')})`); filters.push(`(${tagConditions.join(' OR ')})`);
} }
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
} }
// 构建分页 // 构建分页条件
export function buildPagination(page?: number, pageSize?: number): string { export function buildPagination(page: number = 1, pageSize: number = 20): string {
const limit = pageSize || 20; const offset = (page - 1) * pageSize;
const offset = ((page || 1) - 1) * limit; return `LIMIT ${pageSize} OFFSET ${offset}`;
return `LIMIT ${limit} OFFSET ${offset}`;
} }
// 构建排序 // 构建排序条件
export function buildOrderBy(sortBy?: string, sortOrder?: 'asc' | 'desc'): string { export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
if (!sortBy) { return `ORDER BY ${sortBy} ${sortOrder}`;
return 'ORDER BY event_time DESC';
}
return `ORDER BY ${sortBy} ${sortOrder || 'desc'}`;
} }
// 执行查询并处理错误 // 执行查询
export async function executeQuery<T>(query: string): Promise<T[]> { export async function executeQuery(query: string) {
console.log('执行查询:', query); // 查询日志
try { try {
const resultSet = await clickhouse.query({ const resultSet = await clickhouse.query({
query, query,
format: 'JSONEachRow' format: 'JSONEachRow',
}); });
const rows = await resultSet.json<T>(); const rows = await resultSet.json();
return Array.isArray(rows) ? rows : [rows]; return rows;
} catch (error) { } catch (error) {
console.error('ClickHouse query error:', error); console.error('查询执行错误:', error);
throw error; throw error;
} }
} }
// 执行查询并返回单结果 // 执行返回单结果的查询
export async function executeQuerySingle<T>(query: string): Promise<T | null> { export async function executeQuerySingle(query: string) {
const results = await executeQuery<T>(query); console.log('执行单一结果查询:', query); // 查询日志
return results.length > 0 ? results[0] : null; 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; export default clickhouse;

View File

@@ -24,6 +24,16 @@ export enum DeviceType {
OTHER = 'other' OTHER = 'other'
} }
// 标签类型
export interface Tag {
id: string;
name: string;
color?: string;
type?: string;
attributes?: Record<string, any>;
team_id?: string;
}
// API 响应基础接口 // API 响应基础接口
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean; success: boolean;