Compare commits
3 Commits
d0e83f697b
...
6940d60510
| Author | SHA1 | Date | |
|---|---|---|---|
| 6940d60510 | |||
| 4e7266240d | |||
| db70602e9f |
@@ -13,6 +13,7 @@ import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
|
|||||||
import { TagSelector } from '@/app/components/ui/TagSelector';
|
import { TagSelector } from '@/app/components/ui/TagSelector';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useShortUrlStore } from '@/app/utils/store';
|
import { useShortUrlStore } from '@/app/utils/store';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// 事件类型定义
|
// 事件类型定义
|
||||||
interface Event {
|
interface Event {
|
||||||
@@ -118,26 +119,165 @@ const extractEventInfo = (event: Event) => {
|
|||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
// 从 URL 获取查询参数
|
// 从 URL 获取查询参数
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const shorturlParam = searchParams.get('shorturl');
|
const shorturlParam = searchParams.get('shorturl');
|
||||||
|
|
||||||
// 使用 Zustand store
|
// 使用 Zustand store
|
||||||
const { selectedShortUrl } = useShortUrlStore();
|
const { selectedShortUrl, setSelectedShortUrl, clearSelectedShortUrl } = useShortUrlStore();
|
||||||
|
|
||||||
// 存储 shorturl 参数
|
// 存储 shorturl 参数
|
||||||
const [selectedShortUrlString, setSelectedShortUrlString] = useState<string | null>(null);
|
const [selectedShortUrlString, setSelectedShortUrlString] = useState<string | null>(null);
|
||||||
|
|
||||||
// 当 URL 参数变化时更新状态
|
// Track hydration state of Zustand persistence
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
// Flag to trigger data fetching
|
||||||
|
const [shouldFetchData, setShouldFetchData] = useState(false);
|
||||||
|
|
||||||
|
// Set hydrated state after initial render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const hydrateTimeout = setTimeout(() => {
|
||||||
|
setIsHydrated(true);
|
||||||
|
}, 100); // Small timeout to ensure store is hydrated
|
||||||
|
|
||||||
|
return () => clearTimeout(hydrateTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 从 API 加载短链接数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHydrated) return; // Skip if not hydrated yet
|
||||||
|
|
||||||
|
// 处理 URL 参数
|
||||||
if (shorturlParam) {
|
if (shorturlParam) {
|
||||||
|
// 保存参数到状态
|
||||||
setSelectedShortUrlString(shorturlParam);
|
setSelectedShortUrlString(shorturlParam);
|
||||||
console.log('Selected shorturl from URL:', shorturlParam);
|
|
||||||
|
|
||||||
// 已经通过 Zustand store 传递了完整数据
|
// 如果 store 中没有选中的短链接或者 store 中的短链接与 URL 参数不匹配,则从 localStorage 更新
|
||||||
if (selectedShortUrl) {
|
if (!selectedShortUrl || selectedShortUrl.shortUrl !== shorturlParam) {
|
||||||
console.log('Complete shortUrl data from store:', selectedShortUrl);
|
// 首先检查 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 参数,直接从 localStorage 中设置
|
||||||
|
setSelectedShortUrl(parsedData.state.selectedShortUrl);
|
||||||
|
// Trigger data fetching
|
||||||
|
setShouldFetchData(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing localStorage data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 localStorage 中没有匹配的数据,则从 API 获取
|
||||||
|
const fetchShortUrlData = async () => {
|
||||||
|
try {
|
||||||
|
let apiUrl = '';
|
||||||
|
|
||||||
|
// Check if shorturlParam is a URL or an ID
|
||||||
|
if (shorturlParam.startsWith('http')) {
|
||||||
|
// Direct match by shortUrl is more reliable than URL parsing
|
||||||
|
const exactApiUrl = `/api/shortlinks/exact?shortUrl=${encodeURIComponent(shorturlParam)}`;
|
||||||
|
console.log('Fetching shorturl by exact match:', exactApiUrl);
|
||||||
|
|
||||||
|
// Try the exact endpoint first
|
||||||
|
const response = await fetch(exactApiUrl);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log('Found shortlink by exact shortUrl match:', result.data);
|
||||||
|
console.log('External ID from exact API:', result.data.externalId);
|
||||||
|
|
||||||
|
if (result.data.externalId) {
|
||||||
|
// Save to sessionStorage for immediate use
|
||||||
|
sessionStorage.setItem('current_shorturl_external_id', result.data.externalId);
|
||||||
|
console.log('Saved external ID to sessionStorage:', result.data.externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set in store and trigger data fetch
|
||||||
|
setSelectedShortUrl(result.data);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldFetchData(true);
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to old method if exact match fails
|
||||||
|
console.log('Exact match failed, trying byUrl endpoint');
|
||||||
|
apiUrl = `/api/shortlinks/byUrl?url=${encodeURIComponent(shorturlParam)}`;
|
||||||
|
} else {
|
||||||
|
// It might be an ID or slug, try the ID endpoint directly
|
||||||
|
apiUrl = `/api/shortlinks/${shorturlParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Still trigger data fetching to show all data instead
|
||||||
|
setShouldFetchData(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// 如果找到匹配的短链接数据
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log('Retrieved shortlink data:', result.data);
|
||||||
|
// Log the external ID explicitly for debugging
|
||||||
|
console.log('External ID from API:', result.data.externalId);
|
||||||
|
|
||||||
|
// 设置到 Zustand store (会自动更新到 localStorage)
|
||||||
|
setSelectedShortUrl(result.data);
|
||||||
|
|
||||||
|
// 强制保证 externalId 被设置到 params
|
||||||
|
const savedExternalId = result.data.externalId;
|
||||||
|
if (savedExternalId) {
|
||||||
|
// Save to sessionStorage for immediate use
|
||||||
|
sessionStorage.setItem('current_shorturl_external_id', savedExternalId);
|
||||||
|
console.log('Saved external ID to sessionStorage:', savedExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly wait for the state update to be applied
|
||||||
|
// before triggering the data fetching
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldFetchData(true);
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
setShouldFetchData(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shorturl data:', error);
|
||||||
|
// Trigger data fetching even if there was an error
|
||||||
|
setShouldFetchData(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchShortUrlData();
|
||||||
|
} else {
|
||||||
|
// If selectedShortUrl already matches URL parameter, trigger data fetching
|
||||||
|
setShouldFetchData(true);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 如果 URL 没有参数,清除文本状态
|
||||||
|
setSelectedShortUrlString(null);
|
||||||
|
|
||||||
|
// 如果 URL 没有参数但 store 中有数据,我们保持 store 中的数据不变
|
||||||
|
// 这样用户在清除 URL 参数后仍能看到之前选择的短链接数据
|
||||||
|
|
||||||
|
// Trigger data fetching since no shorturl parameter in URL
|
||||||
|
setShouldFetchData(true);
|
||||||
}
|
}
|
||||||
}, [shorturlParam, selectedShortUrl]);
|
}, [shorturlParam, selectedShortUrl, setSelectedShortUrl, isHydrated]);
|
||||||
|
|
||||||
// 默认日期范围为最近7天
|
// 默认日期范围为最近7天
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -165,7 +305,12 @@ export default function AnalyticsPage() {
|
|||||||
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
|
||||||
|
// 添加 Snackbar 状态
|
||||||
|
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!shouldFetchData) return; // Don't fetch data until explicitly triggered
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -183,6 +328,35 @@ export default function AnalyticsPage() {
|
|||||||
pageSize: pageSize.toString()
|
pageSize: pageSize.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify the shortUrl data is loaded and the externalId exists
|
||||||
|
// before adding the linkId parameter
|
||||||
|
if (selectedShortUrl) {
|
||||||
|
console.log('Current selectedShortUrl data:', selectedShortUrl);
|
||||||
|
|
||||||
|
if (selectedShortUrl.externalId) {
|
||||||
|
params.append('linkId', selectedShortUrl.externalId);
|
||||||
|
console.log('Adding linkId (externalId) to requests:', selectedShortUrl.externalId);
|
||||||
|
} else {
|
||||||
|
// Try to get externalId from sessionStorage as backup
|
||||||
|
const savedExternalId = sessionStorage.getItem('current_shorturl_external_id');
|
||||||
|
if (savedExternalId) {
|
||||||
|
params.append('linkId', savedExternalId);
|
||||||
|
console.log('Adding linkId from sessionStorage:', savedExternalId);
|
||||||
|
} else {
|
||||||
|
// External ID is missing - this will result in no data being returned
|
||||||
|
console.warn('WARNING: externalId is missing in the shortUrl data - no results will be returned!', selectedShortUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We now know the events table exclusively uses external_id format, so never fall back to id
|
||||||
|
|
||||||
|
// Add an extra log to debug the issue
|
||||||
|
console.log('Complete shorturl data:', JSON.stringify({
|
||||||
|
id: selectedShortUrl.id,
|
||||||
|
externalId: selectedShortUrl.externalId,
|
||||||
|
shortUrl: selectedShortUrl.shortUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// 添加团队ID参数 - 支持多个团队
|
// 添加团队ID参数 - 支持多个团队
|
||||||
if (selectedTeamIds.length > 0) {
|
if (selectedTeamIds.length > 0) {
|
||||||
selectedTeamIds.forEach(teamId => {
|
selectedTeamIds.forEach(teamId => {
|
||||||
@@ -204,15 +378,38 @@ export default function AnalyticsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录构建的 URL,以确保参数正确包含
|
||||||
|
const summaryUrl = `${baseUrl}/summary?${params.toString()}`;
|
||||||
|
const timeSeriesUrl = `${baseUrl}/time-series?${params.toString()}`;
|
||||||
|
const geoUrl = `${baseUrl}/geo?${params.toString()}`;
|
||||||
|
const devicesUrl = `${baseUrl}/devices?${params.toString()}`;
|
||||||
|
const eventsUrl = `${baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
|
console.log('Final API URLs being called:');
|
||||||
|
console.log('- Summary API:', summaryUrl);
|
||||||
|
console.log('- TimeSeries API:', timeSeriesUrl);
|
||||||
|
console.log(`- Params contain linkId? ${params.has('linkId')}`);
|
||||||
|
console.log(`- All params: ${params.toString()}`);
|
||||||
|
|
||||||
// 并行获取所有数据
|
// 并行获取所有数据
|
||||||
const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([
|
const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([
|
||||||
fetch(`${baseUrl}/summary?${params.toString()}`),
|
fetch(summaryUrl),
|
||||||
fetch(`${baseUrl}/time-series?${params.toString()}`),
|
fetch(timeSeriesUrl),
|
||||||
fetch(`${baseUrl}/geo?${params.toString()}`),
|
fetch(geoUrl),
|
||||||
fetch(`${baseUrl}/devices?${params.toString()}`),
|
fetch(devicesUrl),
|
||||||
fetch(`${baseUrl}?${params.toString()}`)
|
fetch(eventsUrl)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 添加额外日志,记录完整的 URL 请求
|
||||||
|
console.log('Summary API URL:', summaryUrl);
|
||||||
|
|
||||||
|
if (selectedShortUrl?.externalId) {
|
||||||
|
console.log('Verifying linkId is in params:',
|
||||||
|
`linkId=${selectedShortUrl.externalId}`,
|
||||||
|
`included: ${params.toString().includes(`linkId=${selectedShortUrl.externalId}`)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([
|
const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([
|
||||||
summaryRes.json(),
|
summaryRes.json(),
|
||||||
timeSeriesRes.json(),
|
timeSeriesRes.json(),
|
||||||
@@ -249,7 +446,28 @@ export default function AnalyticsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, currentPage, pageSize]);
|
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, currentPage, pageSize, selectedShortUrl, shouldFetchData]);
|
||||||
|
|
||||||
|
// 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -269,16 +487,106 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug info - remove in production */}
|
||||||
|
{process.env.NODE_ENV !== 'production' && (
|
||||||
|
<div className="mb-4 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-80">
|
||||||
|
<h3 className="font-bold mb-1">Debug Info:</h3>
|
||||||
|
<div>
|
||||||
|
<strong>Hydrated:</strong> {isHydrated ? 'Yes' : 'No'} |
|
||||||
|
<strong> Should Fetch:</strong> {shouldFetchData ? 'Yes' : 'No'} |
|
||||||
|
<strong> Has ShortUrl:</strong> {selectedShortUrl ? 'Yes' : 'No'}
|
||||||
|
</div>
|
||||||
|
{selectedShortUrl && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<strong>ShortUrl ID:</strong> {selectedShortUrl.id} |
|
||||||
|
<strong> ExternalId:</strong> {selectedShortUrl.externalId || 'MISSING'} |
|
||||||
|
<strong> URL:</strong> {selectedShortUrl.shortUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-xs text-red-500">
|
||||||
|
<strong>IMPORTANT: </strong>
|
||||||
|
The events table uses <code>external_id</code> as <code>link_id</code>, not the UUID format.
|
||||||
|
External ID format sample: <code>cm8x34sdr0007m11yh1xe6qc2</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full link data for debugging */}
|
||||||
|
{selectedShortUrl && (
|
||||||
|
<div className="mt-3 border-t pt-2">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-medium text-blue-600">Show Full Link Data</summary>
|
||||||
|
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
|
||||||
|
{JSON.stringify(selectedShortUrl, null, 2)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL Parameters */}
|
||||||
|
<div className="mt-3 border-t pt-2">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-medium text-blue-600">API Request URLs</summary>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div><strong>Summary API URL:</strong> {`/api/events/summary?${new URLSearchParams({
|
||||||
|
startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||||
|
endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||||
|
...(selectedShortUrl?.externalId ? { linkId: selectedShortUrl.externalId } : {})
|
||||||
|
}).toString()}`}</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local Storage Data */}
|
||||||
|
<div className="mt-3 border-t pt-2">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-medium text-blue-600">LocalStorage Data</summary>
|
||||||
|
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
|
||||||
|
{typeof window !== 'undefined' && localStorage.getItem('shorturl-storage') ?
|
||||||
|
JSON.stringify(JSON.parse(localStorage.getItem('shorturl-storage') || '{}'), null, 2) :
|
||||||
|
'No localStorage data'}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
|
<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">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
{/* 如果有选定的 shorturl,可以显示一个提示,显示更多详细信息 */}
|
{/* 如果有选定的 shorturl,可以显示一个提示,显示更多详细信息 */}
|
||||||
{selectedShortUrl && (
|
{selectedShortUrl && (
|
||||||
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-md text-sm flex flex-col">
|
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-md text-sm flex flex-col">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">{selectedShortUrl.title || 'Untitled'}</span>
|
<div className="flex items-center">
|
||||||
<span className="mx-2">-</span>
|
<span className="font-medium">{selectedShortUrl.title || 'Untitled'}</span>
|
||||||
<span>{selectedShortUrl.shortUrl}</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.substring(0, 8)}...)</span>}
|
||||||
|
{selectedShortUrl.externalId && <span className="ml-2 text-blue-500">(External ID: {selectedShortUrl.externalId})</span>}
|
||||||
</div>
|
</div>
|
||||||
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
|
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
@@ -294,51 +602,67 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
{/* 如果只有 URL 参数但没有完整数据,则显示简单提示 */}
|
{/* 如果只有 URL 参数但没有完整数据,则显示简单提示 */}
|
||||||
{selectedShortUrlString && !selectedShortUrl && (
|
{selectedShortUrlString && !selectedShortUrl && (
|
||||||
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-md text-sm flex items-center">
|
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-md text-sm flex items-center justify-between">
|
||||||
<span>Filtered by Short URL:</span>
|
<div>
|
||||||
<span className="ml-2 font-medium">{selectedShortUrlString}</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TeamSelector
|
|
||||||
value={selectedTeamIds}
|
{/* 只在没有选中 shorturl 时显示筛选选择器 */}
|
||||||
onChange={(value) => {
|
{!selectedShortUrl && (
|
||||||
const newTeamIds = Array.isArray(value) ? value : [value];
|
<>
|
||||||
|
<TeamSelector
|
||||||
// Check if team selection has changed
|
value={selectedTeamIds}
|
||||||
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
|
onChange={(value) => {
|
||||||
// Clear project selection when team changes
|
const newTeamIds = Array.isArray(value) ? value : [value];
|
||||||
setSelectedProjectIds([]);
|
|
||||||
|
// Check if team selection has changed
|
||||||
// Update team selection
|
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
|
||||||
setSelectedTeamIds(newTeamIds);
|
// Clear project selection when team changes
|
||||||
}
|
setSelectedProjectIds([]);
|
||||||
}}
|
|
||||||
className="w-[250px]"
|
// Update team selection
|
||||||
multiple={true}
|
setSelectedTeamIds(newTeamIds);
|
||||||
/>
|
}
|
||||||
<ProjectSelector
|
}}
|
||||||
value={selectedProjectIds}
|
className="w-[250px]"
|
||||||
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
multiple={true}
|
||||||
className="w-[250px]"
|
/>
|
||||||
multiple={true}
|
<ProjectSelector
|
||||||
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
value={selectedProjectIds}
|
||||||
/>
|
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
||||||
<TagSelector
|
className="w-[250px]"
|
||||||
value={selectedTagNames}
|
multiple={true}
|
||||||
onChange={(value) => {
|
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||||
// TagSelector返回的是标签名称
|
/>
|
||||||
if (Array.isArray(value)) {
|
<TagSelector
|
||||||
setSelectedTagNames(value);
|
value={selectedTagNames}
|
||||||
} else {
|
onChange={(value) => {
|
||||||
setSelectedTagNames(value ? [value] : []);
|
// TagSelector返回的是标签名称
|
||||||
}
|
if (Array.isArray(value)) {
|
||||||
// 我们需要将标签名称映射回ID,但由于TagSelector内部已经做了处理
|
setSelectedTagNames(value);
|
||||||
// 这里不需要额外的映射代码,selectedTagNames存储名称即可
|
} else {
|
||||||
}}
|
setSelectedTagNames(value ? [value] : []);
|
||||||
className="w-[250px]"
|
}
|
||||||
multiple={true}
|
// 我们需要将标签名称映射回ID,但由于TagSelector内部已经做了处理
|
||||||
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
// 这里不需要额外的映射代码,selectedTagNames存储名称即可
|
||||||
/>
|
}}
|
||||||
|
className="w-[250px]"
|
||||||
|
multiple={true}
|
||||||
|
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
@@ -346,97 +670,102 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 显示团队选择信息 */}
|
{/* 仅在未选中 shorturl 且有选择的筛选条件时显示筛选条件标签 */}
|
||||||
{selectedTeamIds.length > 0 && (
|
{!selectedShortUrl && (
|
||||||
<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:'}
|
{selectedTeamIds.length > 0 && (
|
||||||
</span>
|
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||||
<div className="flex flex-wrap gap-2">
|
<span className="text-blue-700 font-medium mr-2">
|
||||||
{selectedTeamIds.map(teamId => (
|
{selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'}
|
||||||
<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>
|
</span>
|
||||||
))}
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedTeamIds.length > 0 && (
|
{selectedTeamIds.map(teamId => (
|
||||||
<button
|
<span key={teamId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||||
onClick={() => setSelectedTeamIds([])}
|
{teamId}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
<button
|
||||||
>
|
onClick={() => setSelectedTeamIds(selectedTeamIds.filter(id => id !== teamId))}
|
||||||
Clear all
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
</button>
|
>
|
||||||
)}
|
×
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</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 && (
|
{selectedProjectIds.length > 0 && (
|
||||||
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||||
<span className="text-blue-700 font-medium mr-2">
|
<span className="text-blue-700 font-medium mr-2">
|
||||||
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
|
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedProjectIds.map(projectId => (
|
|
||||||
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
|
||||||
{projectId}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedProjectIds.length > 0 && (
|
{selectedProjectIds.map(projectId => (
|
||||||
<button
|
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||||
onClick={() => setSelectedProjectIds([])}
|
{projectId}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
<button
|
||||||
>
|
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
|
||||||
Clear all
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
</button>
|
>
|
||||||
)}
|
×
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</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 && (
|
{selectedTagNames.length > 0 && (
|
||||||
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
|
||||||
<span className="text-blue-700 font-medium mr-2">
|
<span className="text-blue-700 font-medium mr-2">
|
||||||
{selectedTagNames.length === 1 ? 'Tag filter:' : 'Tags filter:'}
|
{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>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedTagNames.length > 0 && (
|
{selectedTagNames.map(tagName => (
|
||||||
<button
|
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||||
onClick={() => setSelectedTagNames([])}
|
{tagName}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
<button
|
||||||
>
|
onClick={() => {
|
||||||
Clear all
|
// 移除对应的标签名称
|
||||||
</button>
|
setSelectedTagNames(selectedTagNames.filter(name => name !== tagName));
|
||||||
)}
|
}}
|
||||||
</div>
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
</div>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{selectedTagNames.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTagNames([])}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 仪表板内容 - 现在放在事件列表之后 */}
|
{/* 仪表板内容 - 现在放在事件列表之后 */}
|
||||||
@@ -560,7 +889,7 @@ export default function AnalyticsPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
<span className="font-medium">{info.linkName}</span>
|
<span className="font-medium">{info.linkName}</span>
|
||||||
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
|
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
|
||||||
ID: {event.link_id?.substring(0, 8) || '-'}
|
ID: {event.link_id || '-'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export async function GET(request: NextRequest) {
|
|||||||
const projectIds = searchParams.getAll('projectId');
|
const projectIds = searchParams.getAll('projectId');
|
||||||
const tagIds = searchParams.getAll('tagId');
|
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);
|
||||||
|
console.log('Summary API full parameters:', Object.fromEntries(searchParams.entries()));
|
||||||
|
console.log('Summary API URL:', request.url);
|
||||||
|
|
||||||
const summary = await getEventsSummary({
|
const summary = await getEventsSummary({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
endTime: searchParams.get('endTime') || undefined,
|
||||||
|
|||||||
140
app/api/shortlinks/[id]/route.ts
Normal file
140
app/api/shortlinks/[id]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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
|
||||||
|
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: 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 ID:', error);
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/shortlinks/byUrl/route.ts
Normal file
142
app/api/shortlinks/byUrl/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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 || '',
|
||||||
|
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: new URL(shortUrl || 'https://example.com').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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/shortlinks/exact/route.ts
Normal file
142
app/api/shortlinks/exact/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
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: new URL(shortUrlValue || 'https://example.com').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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,11 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
},
|
||||||
|
label: (context) => {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
return `${label}: ${Math.round(value)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,9 +165,9 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
callback: (value: number) => {
|
callback: (value: number) => {
|
||||||
if (!value && value !== 0) return '';
|
if (!value && value !== 0) return '';
|
||||||
if (value >= 1000) {
|
if (value >= 1000) {
|
||||||
return `${(value / 1000).toFixed(1)}k`;
|
return `${Math.round(value / 1000)}k`;
|
||||||
}
|
}
|
||||||
return value;
|
return Math.round(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,30 +115,77 @@ export default function LinksPage() {
|
|||||||
// 使用 Zustand store
|
// 使用 Zustand store
|
||||||
const { setSelectedShortUrl } = useShortUrlStore();
|
const { setSelectedShortUrl } = useShortUrlStore();
|
||||||
|
|
||||||
// 处理链接记录点击
|
// 处理点击链接行
|
||||||
const handleLinkClick = (shortUrl: string, link: ShortLink, metadata: any) => {
|
const handleRowClick = (link: any) => {
|
||||||
// 编码 shortUrl 以确保 URL 安全
|
// 解析 attributes 字符串为对象
|
||||||
const encodedShortUrl = encodeURIComponent(shortUrl);
|
let attributes: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
if (link.attributes && typeof link.attributes === 'string') {
|
||||||
|
attributes = JSON.parse(link.attributes || '{}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing link attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// 创建完整的 ShortUrlData 对象
|
// 解析 teams 字符串为数组
|
||||||
const shortUrlData: ShortUrlData = {
|
let teams: any[] = [];
|
||||||
|
try {
|
||||||
|
if (link.teams && typeof link.teams === 'string') {
|
||||||
|
teams = JSON.parse(link.teams || '[]');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 projects 字符串为数组
|
||||||
|
let projects: any[] = [];
|
||||||
|
try {
|
||||||
|
if (link.projects && typeof link.projects === 'string') {
|
||||||
|
projects = JSON.parse(link.projects || '[]');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 tags 字符串为数组
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
if (link.tags && typeof link.tags === 'string') {
|
||||||
|
const parsedTags = JSON.parse(link.tags);
|
||||||
|
if (Array.isArray(parsedTags)) {
|
||||||
|
tags = parsedTags.map((tag: { tag_name?: string }) => tag.tag_name || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 shortUrl 存在
|
||||||
|
const shortUrlValue = attributes.shortUrl || '';
|
||||||
|
|
||||||
|
// 提取用于显示的字段
|
||||||
|
const shortUrlData = {
|
||||||
id: link.id,
|
id: link.id,
|
||||||
slug: metadata.slug,
|
externalId: link.external_id, // 明确添加 externalId 字段
|
||||||
originalUrl: metadata.originalUrl,
|
slug: link.slug,
|
||||||
title: metadata.title,
|
originalUrl: link.original_url,
|
||||||
shortUrl: shortUrl,
|
title: link.title,
|
||||||
teams: metadata.teamNames,
|
shortUrl: shortUrlValue,
|
||||||
tags: metadata.tagNames,
|
teams: teams,
|
||||||
projects: metadata.projectNames,
|
projects: projects,
|
||||||
createdAt: metadata.createdAt,
|
tags: tags,
|
||||||
domain: metadata.domain
|
createdAt: link.created_at,
|
||||||
|
domain: shortUrlValue ? new URL(shortUrlValue).hostname : 'shorturl.example.com'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用 Zustand store 保存数据
|
// 打印完整数据,确保 externalId 被包含
|
||||||
|
console.log('Setting shortURL data in store with externalId:', link.external_id);
|
||||||
|
|
||||||
|
// 将数据保存到 Zustand store
|
||||||
setSelectedShortUrl(shortUrlData);
|
setSelectedShortUrl(shortUrlData);
|
||||||
|
|
||||||
// 导航到 analytics 页面并带上参数
|
// 导航到分析页面,并在 URL 中包含 shortUrl 参数
|
||||||
router.push(`/analytics?shorturl=${encodedShortUrl}`);
|
router.push(`/analytics?shorturl=${encodeURIComponent(shortUrlValue)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract link metadata from attributes
|
// Extract link metadata from attributes
|
||||||
@@ -423,7 +470,7 @@ export default function LinksPage() {
|
|||||||
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleLinkClick(shortUrl, link, metadata)}>
|
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleRowClick(link)}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<span className="font-medium text-gray-900">{metadata.title}</span>
|
<span className="font-medium text-gray-900">{metadata.title}</span>
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { create } from 'zustand';
|
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 数据类型
|
// 定义 ShortUrl 数据类型
|
||||||
export interface ShortUrlData {
|
export interface ShortUrlData {
|
||||||
id: string;
|
id: string;
|
||||||
|
externalId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
shortUrl: string;
|
shortUrl: string;
|
||||||
teams?: any[];
|
teams?: TeamData[];
|
||||||
projects?: any[];
|
projects?: ProjectData[];
|
||||||
tags?: any[];
|
tags?: string[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
@@ -21,9 +36,17 @@ interface ShortUrlStore {
|
|||||||
clearSelectedShortUrl: () => void;
|
clearSelectedShortUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 store
|
// 创建 store 并使用 persist 中间件保存到 localStorage
|
||||||
export const useShortUrlStore = create<ShortUrlStore>((set) => ({
|
export const useShortUrlStore = create<ShortUrlStore>()(
|
||||||
selectedShortUrl: null,
|
persist(
|
||||||
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
|
(set) => ({
|
||||||
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
|
selectedShortUrl: null,
|
||||||
}));
|
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
|
||||||
|
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'shorturl-storage', // localStorage 中的 key 名称
|
||||||
|
partialize: (state) => ({ selectedShortUrl: state.selectedShortUrl }), // 只持久化 selectedShortUrl
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export async function getEventsSummary(params: {
|
|||||||
projectIds?: string[];
|
projectIds?: string[];
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
}): Promise<EventsSummary> {
|
}): Promise<EventsSummary> {
|
||||||
|
console.log('getEventsSummary received params:', params);
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
console.log('getEventsSummary built filter:', filter);
|
||||||
|
|
||||||
// 获取基本统计数据
|
// 获取基本统计数据
|
||||||
const baseQuery = `
|
const baseQuery = `
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
|
|||||||
|
|
||||||
// 构建通用过滤条件
|
// 构建通用过滤条件
|
||||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||||
|
console.log('buildFilter received params:', JSON.stringify(params));
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
// 添加日期过滤条件
|
// 添加日期过滤条件
|
||||||
@@ -43,6 +44,7 @@ export function buildFilter(params: Partial<EventsQueryParams>): string {
|
|||||||
|
|
||||||
// 添加链接ID过滤条件
|
// 添加链接ID过滤条件
|
||||||
if (params.linkId) {
|
if (params.linkId) {
|
||||||
|
console.log('Adding link_id filter:', params.linkId);
|
||||||
filters.push(`link_id = '${params.linkId}'`);
|
filters.push(`link_id = '${params.linkId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string =
|
|||||||
|
|
||||||
// 执行查询
|
// 执行查询
|
||||||
export async function executeQuery(query: string) {
|
export async function executeQuery(query: string) {
|
||||||
console.log('执行查询:', query); // 查询日志
|
console.log('Executing query:', query); // 查询日志
|
||||||
try {
|
try {
|
||||||
const resultSet = await clickhouse.query({
|
const resultSet = await clickhouse.query({
|
||||||
query,
|
query,
|
||||||
@@ -117,7 +119,7 @@ export async function executeQuery(query: string) {
|
|||||||
|
|
||||||
// 执行返回单一结果的查询
|
// 执行返回单一结果的查询
|
||||||
export async function executeQuerySingle(query: string) {
|
export async function executeQuerySingle(query: string) {
|
||||||
console.log('执行单一结果查询:', query); // 查询日志
|
console.log('Executing single result query:', query); // 查询日志
|
||||||
try {
|
try {
|
||||||
const resultSet = await clickhouse.query({
|
const resultSet = await clickhouse.query({
|
||||||
query,
|
query,
|
||||||
|
|||||||
Reference in New Issue
Block a user