Files
shorturl-analytics/app/links/page.tsx
2025-03-21 12:08:37 +08:00

473 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from 'react';
import CreateLinkModal from '../components/ui/CreateLinkModal';
import { Link, StatsOverview, Tag } from '../api/types';
// Define type for link data
interface LinkData {
name: string;
originalUrl: string;
customSlug: string;
expiresAt: string;
tags: string[];
}
// 映射API数据到UI所需格式
interface UILink {
id: string;
name: string;
shortUrl: string;
originalUrl: string;
creator: string;
createdAt: string;
visits: number;
visitChange: number;
uniqueVisitors: number;
uniqueVisitorsChange: number;
avgTime: string;
avgTimeChange: number;
conversionRate: number;
conversionChange: number;
status: string;
tags: string[];
}
export default function LinksPage() {
const [links, setLinks] = useState<UILink[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [stats, setStats] = useState<StatsOverview>({
totalLinks: 0,
activeLinks: 0,
totalVisits: 0,
conversionRate: 0
});
const [searchQuery, setSearchQuery] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 映射API数据到UI所需格式的函数
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
// 生成短URL显示 - 因为数据库中没有short_url字段
const shortUrlDisplay = generateShortUrlDisplay(apiLink.link_id, apiLink.original_url);
return {
id: apiLink.link_id,
name: apiLink.title || 'Untitled Link',
shortUrl: shortUrlDisplay,
originalUrl: apiLink.original_url,
creator: apiLink.created_by,
createdAt: new Date(apiLink.created_at).toLocaleDateString(),
visits: apiLink.visits,
visitChange: 0, // API doesn't provide change data yet
uniqueVisitors: apiLink.unique_visits,
uniqueVisitorsChange: 0,
avgTime: '0m 0s', // API doesn't provide average time yet
avgTimeChange: 0,
conversionRate: 0, // API doesn't provide conversion rate yet
conversionChange: 0,
status: apiLink.is_active ? 'active' : 'inactive',
tags: apiLink.tags || []
};
};
// 从link_id和原始URL生成短URL显示
const generateShortUrlDisplay = (linkId: string, originalUrl: string): string => {
try {
// 尝试从原始URL提取域名
const urlObj = new URL(originalUrl);
const domain = urlObj.hostname.replace('www.', '');
// 使用link_id的前8个字符作为短代码
const shortCode = linkId.substring(0, 8);
return `${domain}/${shortCode}`;
} catch {
// 如果URL解析失败返回一个基于linkId的默认值
return `short.link/${linkId.substring(0, 8)}`;
}
};
// 获取链接数据
useEffect(() => {
const fetchLinks = async () => {
try {
setIsLoading(true);
setError(null);
// 获取链接列表
const linksResponse = await fetch('/api/links');
if (!linksResponse.ok) {
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
}
const linksData = await linksResponse.json();
// 获取标签列表
const tagsResponse = await fetch('/api/tags');
if (!tagsResponse.ok) {
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
}
const tagsData = await tagsResponse.json();
// 获取统计数据
const statsResponse = await fetch('/api/stats');
if (!statsResponse.ok) {
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
}
const statsData = await statsResponse.json();
// 处理并设置数据
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
setLinks(uiLinks);
setAllTags(tagsData);
setStats(statsData);
} catch (err) {
console.error('Data loading failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
fetchLinks();
}, []);
const filteredLinks = links.filter(link =>
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.shortUrl.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
const handleOpenLinkDetails = (id: string) => {
window.location.href = `/links/${id}`;
};
const handleCreateLink = async (linkData: LinkData) => {
try {
setIsLoading(true);
// 在实际应用中,这里会发送 POST 请求到 API
console.log('创建链接:', linkData);
// 刷新链接列表
const response = await fetch('/api/links');
if (!response.ok) {
throw new Error(`刷新链接列表失败: ${response.statusText}`);
}
const newData = await response.json();
const uiLinks = newData.data.map(mapApiLinkToUiLink);
setLinks(uiLinks);
setShowCreateModal(false);
} catch (err) {
console.error('创建链接失败:', err);
setError(err instanceof Error ? err.message : '未知错误');
} finally {
setIsLoading(false);
}
};
// 加载状态
if (isLoading && links.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="p-4 text-center">
<div className="w-12 h-12 mx-auto border-4 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
<p className="mt-4 text-lg text-foreground">Loading data...</p>
</div>
</div>
);
}
// 错误状态
if (error && links.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="p-6 text-center rounded-lg bg-red-500/10">
<svg className="w-12 h-12 mx-auto text-accent-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-bold text-foreground">Loading Failed</h2>
<p className="mt-2 text-text-secondary">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 mt-4 text-white rounded-lg bg-accent-blue hover:bg-blue-600"
>
Reload
</button>
</div>
</div>
);
}
return (
<div className="container px-4 py-8 mx-auto">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:justify-between md:items-center">
<div>
<h1 className="text-2xl font-bold text-foreground">Link Management</h1>
<p className="mt-1 text-sm text-text-secondary">
View and manage all your shortened links
</p>
</div>
<div className="flex gap-2">
<div className="relative flex-1 min-w-[200px]">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg className="w-4 h-4 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="search"
className="block w-full p-2.5 pl-10 text-sm border rounded-lg bg-card-bg border-card-border text-foreground placeholder-text-secondary focus:ring-accent-blue focus:border-accent-blue"
placeholder="Search links..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2.5 bg-accent-blue text-white rounded-lg text-sm font-medium hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
<span className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Link
</span>
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-blue bg-blue-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Total Links</p>
<p className="text-2xl font-semibold text-foreground">{stats.totalLinks}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-green bg-green-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Active Links</p>
<p className="text-2xl font-semibold text-foreground">{stats.activeLinks}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-purple bg-purple-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Total Visits</p>
<p className="text-2xl font-semibold text-foreground">{stats.totalVisits.toLocaleString()}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full bg-amber-500/10 text-accent-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Conversion Rate</p>
<p className="text-2xl font-semibold text-foreground">{(stats.conversionRate * 100).toFixed(1)}%</p>
</div>
</div>
</div>
</div>
{/* Links Table */}
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-text-secondary">
<thead className="text-xs uppercase border-b bg-card-bg/60 text-text-secondary border-card-border">
<tr>
<th scope="col" className="px-6 py-3">Link Info</th>
<th scope="col" className="px-6 py-3">Visits</th>
<th scope="col" className="px-6 py-3">Unique Visitors</th>
<th scope="col" className="px-6 py-3">Avg Time</th>
<th scope="col" className="px-6 py-3">Conversion</th>
<th scope="col" className="px-6 py-3">Status</th>
<th scope="col" className="px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{isLoading && links.length === 0 ? (
<tr className="border-b bg-card-bg border-card-border">
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
<div className="flex items-center justify-center">
<div className="w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
<span className="ml-2">Loading...</span>
</div>
</td>
</tr>
) : filteredLinks.length === 0 ? (
<tr className="border-b bg-card-bg border-card-border">
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
No links found matching your search criteria
</td>
</tr>
) : (
filteredLinks.map((link) => (
<tr
key={link.id}
className="border-b cursor-pointer bg-card-bg border-card-border hover:bg-card-bg/80"
onClick={() => handleOpenLinkDetails(link.id)}
>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.name}</div>
<div className="text-xs text-accent-blue">{link.shortUrl}</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.visits.toLocaleString()}</div>
<div className={`text-xs flex items-center ${link.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.visitChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.visitChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.uniqueVisitors.toLocaleString()}</div>
<div className={`text-xs flex items-center ${link.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.uniqueVisitorsChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.uniqueVisitorsChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.avgTime}</div>
<div className={`text-xs flex items-center ${link.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.avgTimeChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.avgTimeChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.conversionRate}%</div>
<div className={`text-xs flex items-center ${link.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.conversionChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.conversionChange)}%
</div>
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
link.status === 'active'
? 'bg-green-500/10 text-accent-green'
: link.status === 'inactive'
? 'bg-gray-500/10 text-text-secondary'
: 'bg-red-500/10 text-accent-red'
}`}
>
{link.status === 'active' ? 'Active' : link.status === 'inactive' ? 'Inactive' : 'Expired'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenLinkDetails(link.id);
}}
className="text-sm font-medium text-accent-blue hover:underline"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Tags Section */}
{allTags.length > 0 && (
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<h2 className="mb-4 text-lg font-medium text-foreground">Tags</h2>
<div className="flex flex-wrap gap-2">
{allTags.map(tagItem => (
<span
key={tagItem.tag}
className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full text-accent-blue bg-blue-500/10"
onClick={() => setSearchQuery(tagItem.tag)}
style={{ cursor: 'pointer' }}
>
{tagItem.tag}
<span className="ml-1.5 text-xs bg-blue-500/20 px-1.5 py-0.5 rounded-full">
{tagItem.count}
</span>
</span>
))}
</div>
</div>
)}
</div>
{/* Create Link Modal */}
{showCreateModal && (
<CreateLinkModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateLink}
/>
)}
</div>
);
}