hide filter

This commit is contained in:
2025-04-08 00:03:13 +08:00
parent d0e83f697b
commit db70602e9f
6 changed files with 454 additions and 152 deletions

View File

@@ -13,6 +13,7 @@ import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import { TagSelector } from '@/app/components/ui/TagSelector';
import { useSearchParams } from 'next/navigation';
import { useShortUrlStore } from '@/app/utils/store';
import { useRouter } from 'next/navigation';
// 事件类型定义
interface Event {
@@ -121,23 +122,75 @@ export default function AnalyticsPage() {
const shorturlParam = searchParams.get('shorturl');
// 使用 Zustand store
const { selectedShortUrl } = useShortUrlStore();
const { selectedShortUrl, setSelectedShortUrl, clearSelectedShortUrl } = useShortUrlStore();
// 存储 shorturl 参数
const [selectedShortUrlString, setSelectedShortUrlString] = useState<string | null>(null);
// 当 URL 参数变化时更新状态
// 从 API 加载短链接数据
useEffect(() => {
// 处理 URL 参数
if (shorturlParam) {
// 保存参数到状态
setSelectedShortUrlString(shorturlParam);
console.log('Selected shorturl from URL:', shorturlParam);
// 已经通过 Zustand store 传递了完整数据
if (selectedShortUrl) {
console.log('Complete shortUrl data from store:', selectedShortUrl);
// 如果 store 中没有选中的短链接或者 store 中的短链接与 URL 参数不匹配,则从 localStorage 更新
if (!selectedShortUrl || selectedShortUrl.shortUrl !== shorturlParam) {
// 首先检查 localStorage 是否已有此数据
const localStorageData = localStorage.getItem('shorturl-storage');
if (localStorageData) {
try {
const parsedData = JSON.parse(localStorageData);
if (parsedData.state?.selectedShortUrl && parsedData.state.selectedShortUrl.shortUrl === shorturlParam) {
// 数据已存在于 localStorage 且匹配 URL 参数,无需操作
return;
}
} catch (e) {
console.error('Error parsing localStorage data:', e);
}
}
// 如果 localStorage 中没有匹配的数据,则从 API 获取
const fetchShortUrlData = async () => {
try {
// 构建带有 URL 参数的查询字符串
const encodedUrl = encodeURIComponent(shorturlParam);
const apiUrl = `/api/shortlinks/byUrl?url=${encodedUrl}`;
console.log('Fetching shorturl data from:', apiUrl);
// 使用 API 端点获取短链接数据
const response = await fetch(apiUrl);
if (!response.ok) {
console.error('Failed to fetch shorturl data:', response.statusText);
return;
}
const result = await response.json();
// 如果找到匹配的短链接数据
if (result.success && result.data) {
console.log('Retrieved shortlink data:', result.data);
// 设置到 Zustand store (会自动更新到 localStorage)
setSelectedShortUrl(result.data);
}
} catch (error) {
console.error('Error fetching shorturl data:', error);
}
};
fetchShortUrlData();
}
} else {
// 如果 URL 没有参数,清除文本状态
setSelectedShortUrlString(null);
// 如果 URL 没有参数但 store 中有数据,我们保持 store 中的数据不变
// 这样用户在清除 URL 参数后仍能看到之前选择的短链接数据
}
}, [shorturlParam, selectedShortUrl]);
}, [shorturlParam, selectedShortUrl, setSelectedShortUrl]);
// 默认日期范围为最近7天
const today = new Date();
@@ -165,6 +218,11 @@ export default function AnalyticsPage() {
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const router = useRouter();
// 添加 Snackbar 状态
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
@@ -183,6 +241,12 @@ export default function AnalyticsPage() {
pageSize: pageSize.toString()
});
// Add linkId parameter if a shorturl is selected
if (selectedShortUrl && selectedShortUrl.id) {
params.append('linkId', selectedShortUrl.id);
console.log('Adding linkId to requests:', selectedShortUrl.id);
}
// 添加团队ID参数 - 支持多个团队
if (selectedTeamIds.length > 0) {
selectedTeamIds.forEach(teamId => {
@@ -249,7 +313,28 @@ export default function AnalyticsPage() {
};
fetchData();
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, currentPage, pageSize]);
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, currentPage, pageSize, selectedShortUrl]);
// Function to clear the shorturl filter
const handleClearShortUrlFilter = () => {
// Clear the shorturl from Zustand store
clearSelectedShortUrl();
// Create a new URL object to manipulate the current URL
const currentUrl = new URL(window.location.href);
// Remove the shorturl parameter
currentUrl.searchParams.delete('shorturl');
// Get all other parameters and preserve them
const newUrl = `/analytics${currentUrl.search}`;
// Navigate to the updated URL
router.push(newUrl);
// Show a message to the user
setIsSnackbarOpen(true);
};
if (loading) {
return (
@@ -269,16 +354,43 @@ export default function AnalyticsPage() {
return (
<div className="container mx-auto px-4 py-8">
{/* Notification Snackbar */}
{isSnackbarOpen && (
<div className="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50 flex items-center">
<span>URL filter cleared</span>
<button
onClick={() => setIsSnackbarOpen(false)}
className="ml-3 text-white hover:text-gray-200 p-1"
aria-label="Close notification"
>
×
</button>
</div>
)}
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{/* 如果有选定的 shorturl可以显示一个提示显示更多详细信息 */}
{selectedShortUrl && (
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-md text-sm flex flex-col">
<div className="flex items-center">
<span className="font-medium">{selectedShortUrl.title || 'Untitled'}</span>
<span className="mx-2">-</span>
<span>{selectedShortUrl.shortUrl}</span>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="font-medium">{selectedShortUrl.title || 'Untitled'}</span>
<span className="mx-2">-</span>
<span>{selectedShortUrl.shortUrl}</span>
</div>
<button
onClick={handleClearShortUrlFilter}
className="ml-3 text-blue-700 hover:text-blue-900 p-1 rounded-full hover:bg-blue-200"
aria-label="Clear shorturl filter"
>
×
</button>
</div>
<div className="text-xs mt-1 text-blue-700">
<span>Analytics filtered for this short URL only</span>
{selectedShortUrl.id && <span className="ml-2 text-blue-500">(ID: {selectedShortUrl.id})</span>}
</div>
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -294,51 +406,67 @@ export default function AnalyticsPage() {
{/* 如果只有 URL 参数但没有完整数据,则显示简单提示 */}
{selectedShortUrlString && !selectedShortUrl && (
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-md text-sm flex items-center">
<span>Filtered by Short URL:</span>
<span className="ml-2 font-medium">{selectedShortUrlString}</span>
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-md text-sm flex items-center justify-between">
<div>
<span>Filtered by Short URL:</span>
<span className="ml-2 font-medium">{selectedShortUrlString}</span>
</div>
<button
onClick={handleClearShortUrlFilter}
className="ml-3 text-blue-700 hover:text-blue-900 p-1 rounded-full hover:bg-blue-200"
aria-label="Clear shorturl filter"
>
×
</button>
</div>
)}
<TeamSelector
value={selectedTeamIds}
onChange={(value) => {
const newTeamIds = Array.isArray(value) ? value : [value];
// Check if team selection has changed
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
// Clear project selection when team changes
setSelectedProjectIds([]);
// Update team selection
setSelectedTeamIds(newTeamIds);
}
}}
className="w-[250px]"
multiple={true}
/>
<ProjectSelector
value={selectedProjectIds}
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
<TagSelector
value={selectedTagNames}
onChange={(value) => {
// TagSelector返回的是标签名称
if (Array.isArray(value)) {
setSelectedTagNames(value);
} else {
setSelectedTagNames(value ? [value] : []);
}
// 我们需要将标签名称映射回ID但由于TagSelector内部已经做了处理
// 这里不需要额外的映射代码selectedTagNames存储名称即可
}}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
{/* 只在没有选中 shorturl 时显示筛选选择器 */}
{!selectedShortUrl && (
<>
<TeamSelector
value={selectedTeamIds}
onChange={(value) => {
const newTeamIds = Array.isArray(value) ? value : [value];
// Check if team selection has changed
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
// Clear project selection when team changes
setSelectedProjectIds([]);
// Update team selection
setSelectedTeamIds(newTeamIds);
}
}}
className="w-[250px]"
multiple={true}
/>
<ProjectSelector
value={selectedProjectIds}
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
<TagSelector
value={selectedTagNames}
onChange={(value) => {
// TagSelector返回的是标签名称
if (Array.isArray(value)) {
setSelectedTagNames(value);
} else {
setSelectedTagNames(value ? [value] : []);
}
// 我们需要将标签名称映射回ID但由于TagSelector内部已经做了处理
// 这里不需要额外的映射代码selectedTagNames存储名称即可
}}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
</>
)}
<DateRangePicker
value={dateRange}
onChange={setDateRange}
@@ -346,97 +474,102 @@ export default function AnalyticsPage() {
</div>
</div>
{/* 显示团队选择信息 */}
{selectedTeamIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedTeamIds.map(teamId => (
<span key={teamId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{teamId}
<button
onClick={() => setSelectedTeamIds(selectedTeamIds.filter(id => id !== teamId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
{/* 仅在未选中 shorturl 且有选择的筛选条件时显示筛选条件标签 */}
{!selectedShortUrl && (
<>
{/* 显示团队选择信息 */}
{selectedTeamIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'}
</span>
))}
{selectedTeamIds.length > 0 && (
<button
onClick={() => setSelectedTeamIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedTeamIds.map(teamId => (
<span key={teamId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{teamId}
<button
onClick={() => setSelectedTeamIds(selectedTeamIds.filter(id => id !== teamId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedTeamIds.length > 0 && (
<button
onClick={() => setSelectedTeamIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
{/* 显示项目选择信息 */}
{selectedProjectIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedProjectIds.map(projectId => (
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{projectId}
<button
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
{/* 显示项目选择信息 */}
{selectedProjectIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
</span>
))}
{selectedProjectIds.length > 0 && (
<button
onClick={() => setSelectedProjectIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedProjectIds.map(projectId => (
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{projectId}
<button
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedProjectIds.length > 0 && (
<button
onClick={() => setSelectedProjectIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
{/* 显示标签选择信息 */}
{selectedTagNames.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTagNames.length === 1 ? 'Tag filter:' : 'Tags filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedTagNames.map(tagName => (
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{tagName}
<button
onClick={() => {
// 移除对应的标签名称
setSelectedTagNames(selectedTagNames.filter(name => name !== tagName));
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
{/* 显示标签选择信息 */}
{selectedTagNames.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTagNames.length === 1 ? 'Tag filter:' : 'Tags filter:'}
</span>
))}
{selectedTagNames.length > 0 && (
<button
onClick={() => setSelectedTagNames([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{selectedTagNames.map(tagName => (
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{tagName}
<button
onClick={() => {
// 移除对应的标签名称
setSelectedTagNames(selectedTagNames.filter(name => name !== tagName));
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedTagNames.length > 0 && (
<button
onClick={() => setSelectedTagNames([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
</>
)}
{/* 仪表板内容 - 现在放在事件列表之后 */}

View File

@@ -11,6 +11,10 @@ export async function GET(request: NextRequest) {
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');
console.log('Summary API received linkId:', linkId);
const summary = await getEventsSummary({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,

View File

@@ -0,0 +1,139 @@
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
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 || '',
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: new URL(shortUrl || 'https://example.com').hostname
};
const response: ApiResponse<typeof formattedShortlink> = {
success: true,
data: formattedShortlink
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching shortlink by URL:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -1,4 +1,18 @@
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 {
@@ -7,9 +21,9 @@ export interface ShortUrlData {
originalUrl: string;
title?: string;
shortUrl: string;
teams?: any[];
projects?: any[];
tags?: any[];
teams?: TeamData[];
projects?: ProjectData[];
tags?: string[];
createdAt?: string;
domain?: string;
}
@@ -21,9 +35,17 @@ interface ShortUrlStore {
clearSelectedShortUrl: () => void;
}
// 创建 store
export const useShortUrlStore = create<ShortUrlStore>((set) => ({
selectedShortUrl: null,
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
}));
// 创建 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
}
)
);