links search

This commit is contained in:
2025-04-07 22:08:12 +08:00
parent 694e005101
commit 0c4a67e769
2 changed files with 162 additions and 148 deletions

View File

@@ -18,10 +18,14 @@ export async function GET(request: NextRequest) {
const whereConditions = ['deleted_at IS NULL']; const whereConditions = ['deleted_at IS NULL'];
if (search) { if (search) {
// Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url
whereConditions.push(`( whereConditions.push(`(
title ILIKE '%${search}%' OR
slug ILIKE '%${search}%' OR slug ILIKE '%${search}%' OR
original_url ILIKE '%${search}%' original_url ILIKE '%${search}%' OR
title ILIKE '%${search}%' OR
JSONHas(attributes, 'shortUrl') AND JSONExtractString(attributes, 'shortUrl') ILIKE '%${search}%' OR
arrayExists(x -> JSONExtractString(x, 'team_name') ILIKE '%${search}%', JSONExtractArrayRaw(teams)) OR
arrayExists(x -> JSONExtractString(x, 'tag_name') ILIKE '%${search}%', JSONExtractArrayRaw(tags))
)`); )`);
} }

View File

@@ -20,17 +20,32 @@ interface LinkAttributes {
[key: string]: unknown; [key: string]: unknown;
} }
// Define Link type based on the database schema // 更新 ShortLink 类型定义以匹配 ClickHouse 数据结构
interface ShortLink { interface ShortLink {
id: string; id: string;
external_id: string | null; external_id?: string;
attributes: LinkAttributes | null; type?: string;
type: string | null; slug?: string;
creator_id: string | null; original_url?: string;
created_at: string | null; title?: string;
updated_at: string | null; description?: string;
deleted_at: string | null; attributes: string | Record<string, unknown>;
schema_version: number | null; schema_version?: number;
creator_id?: string;
creator_email?: string;
creator_name?: string;
created_at: string;
updated_at?: string;
deleted_at?: string | null;
projects?: string | Record<string, unknown>[];
teams?: string | Record<string, unknown>[];
tags?: string | Record<string, unknown>[];
qr_codes?: string | Record<string, unknown>[];
channels?: string | Record<string, unknown>[];
favorites?: string | Record<string, unknown>[];
expires_at?: string | null;
click_count?: number;
unique_visitors?: number;
} }
// Define ClickHouse shorturl type // Define ClickHouse shorturl type
@@ -62,69 +77,24 @@ interface ClickHouseShortUrl {
link_attributes?: string; // Optional JSON string containing link-specific attributes link_attributes?: string; // Optional JSON string containing link-specific attributes
} }
// Convert ClickHouse data to ShortLink format // 示例团队数据 - 实际应用中应从API获取
function convertClickHouseToShortLink(chData: ClickHouseShortUrl): ShortLink { const teams = [
// Parse JSON strings { id: 'marketing', name: 'Marketing' },
const attributesJson = JSON.parse(chData.attributes || '{}'); { id: 'sales', name: 'Sales' },
const teamsJson = JSON.parse(chData.teams || '[]'); { id: 'product', name: 'Product' },
const tagsJson = JSON.parse(chData.tags || '[]'); { id: 'engineering', name: 'Engineering' }
];
// Extract team info from the first team if available
const teamInfo = teamsJson.length > 0 ? {
team_id: teamsJson[0].team_id,
team_name: teamsJson[0].team_name
} : {};
// Extract tag names
const tagNames = tagsJson.map((tag: { tag_name: string }) => tag.tag_name);
// Parse link_attributes to get domain if available
let domain = 'shorturl.example.com';
try {
if (chData.link_attributes) {
const linkAttrObj = JSON.parse(chData.link_attributes);
if (linkAttrObj.domain) {
domain = linkAttrObj.domain;
}
}
// Extract domain from shortUrl in attributes if available
const attributesObj = JSON.parse(chData.attributes || '{}');
if (attributesObj.shortUrl) {
try {
const urlObj = new URL(attributesObj.shortUrl);
domain = urlObj.hostname;
} catch (e) {
console.error('Error parsing shortUrl:', e);
}
}
} catch (e) {
console.error('Error parsing link_attributes:', e);
}
// 将 ClickHouse 数据转换为 ShortLink 格式
const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink => {
return { return {
id: chData.id, ...data as any, // 使用类型断言处理泛型记录转换
external_id: chData.external_id, // 确保关键字段存在
type: chData.type, id: data.id as string || '',
creator_id: chData.creator_id, created_at: data.created_at as string || new Date().toISOString(),
created_at: chData.created_at, attributes: data.attributes || '{}'
updated_at: chData.updated_at, };
deleted_at: chData.deleted_at,
schema_version: chData.schema_version,
attributes: {
...attributesJson,
title: chData.title || attributesJson.title || '',
slug: chData.slug,
original_url: chData.original_url,
click_count: chData.click_count,
visits: chData.click_count,
unique_visitors: chData.unique_visitors,
domain: attributesJson.domain || domain,
...teamInfo,
tags: tagNames
}
}; };
}
export default function LinksPage() { export default function LinksPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -136,53 +106,74 @@ export default function LinksPage() {
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [totalLinks, setTotalLinks] = useState(0); const [totalLinks, setTotalLinks] = useState(0);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [searchDebounce, setSearchDebounce] = useState<NodeJS.Timeout | null>(null);
// Extract link metadata from attributes // Extract link metadata from attributes
const getLinkMetadata = (link: ShortLink) => { const getLinkMetadata = (link: ShortLink) => {
const attrs = link.attributes || {}; try {
return { // Parse attributes if it's a string
title: attrs.title || attrs.name || 'Untitled Link', const attributes = typeof link.attributes === 'string'
slug: attrs.slug || 'no-slug', ? JSON.parse(link.attributes)
originalUrl: attrs.original_url || attrs.originalUrl || '#', : link.attributes || {};
visits: attrs.visits || attrs.click_count || 0,
teamId: attrs.team_id || '',
teamName: attrs.team_name || 'Personal',
createdAt: new Date(link.created_at || Date.now()).toLocaleDateString(),
tags: attrs.tags || [],
domain: attrs.domain || 'shorturl.example.com',
};
};
// Filter links by search query // Parse attributes to get domain if available
const filteredLinks = links.length > 0 ? let domain = 'shorturl.example.com';
links.filter(link => { try {
if (!searchQuery && !teamFilter) return true; // Extract domain from shortUrl in attributes if available
const attributesObj = typeof link.attributes === 'string'
? JSON.parse(link.attributes)
: link.attributes || {};
const metadata = getLinkMetadata(link); if (attributesObj.shortUrl) {
const matchesSearch = searchQuery ? try {
(metadata.title.toLowerCase().includes(searchQuery.toLowerCase()) || const urlObj = new URL(attributesObj.shortUrl);
metadata.slug.toLowerCase().includes(searchQuery.toLowerCase()) || domain = urlObj.hostname;
metadata.originalUrl.toLowerCase().includes(searchQuery.toLowerCase())) : } catch (e) {
true; console.error('Error parsing shortUrl:', e);
const matchesTeam = teamFilter ?
metadata.teamId === teamFilter :
true;
return matchesSearch && matchesTeam;
}) : [];
// Get unique teams for filtering
const teams = links.length > 0 ?
Array.from(
links.reduce((teams, link) => {
const metadata = getLinkMetadata(link);
if (metadata.teamId) {
teams.set(metadata.teamId, metadata.teamName);
} }
return teams; }
}, new Map<string, string>()) } catch (e) {
).map(([id, name]) => ({ id, name })) : []; console.error('Error parsing attributes:', e);
}
// Get team name
let teamName = '';
try {
if (link.teams) {
const teams = typeof link.teams === 'string'
? JSON.parse(link.teams)
: link.teams || [];
if (Array.isArray(teams) && teams.length > 0 && teams[0].team_name) {
teamName = teams[0].team_name;
}
}
} catch (e) {
console.error('Error parsing teams:', e);
}
return {
title: link.title || attributes.title || 'Untitled',
slug: link.slug || attributes.slug || '',
domain: domain,
originalUrl: link.original_url || attributes.originalUrl || attributes.original_url || '',
teamName: teamName,
createdAt: new Date(link.created_at).toLocaleDateString(),
visits: link.click_count || 0
};
} catch (error) {
console.error('Error parsing link metadata:', error);
return {
title: 'Error parsing data',
slug: '',
domain: 'shorturl.example.com',
originalUrl: '',
teamName: '',
createdAt: '',
visits: 0
};
}
};
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -231,20 +222,20 @@ export default function LinksPage() {
} }
}; };
// Subscribe to user auth state
const supabase = getSupabaseClient(); const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => { (event: AuthChangeEvent) => {
if (event === 'SIGNED_IN') { if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
fetchLinks(); fetchLinks();
} else if (event === 'SIGNED_OUT') {
setLinks([]);
setError(null);
} }
}); if (event === 'SIGNED_OUT') {
setLinks([]);
}
}
);
supabase.auth.getSession().then(() => {
fetchLinks(); fetchLinks();
});
return () => { return () => {
isMounted = false; isMounted = false;
@@ -252,27 +243,42 @@ export default function LinksPage() {
}; };
}, [currentPage, pageSize, searchQuery, teamFilter]); }, [currentPage, pageSize, searchQuery, teamFilter]);
if (loading) { // Handle search input with debounce
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Clear any existing timeout
if (searchDebounce) {
clearTimeout(searchDebounce);
}
// Set the input value immediately for UI feedback
setSearchQuery(value);
// Set a timeout to actually perform the search
setSearchDebounce(setTimeout(() => {
setCurrentPage(1); // Reset to page 1 when searching
}, 500)); // 500ms debounce
};
if (loading && links.length === 0) {
return ( return (
<div className="flex w-full items-center justify-center p-12"> <div className="flex h-96 w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" /> <Loader2 className="h-8 w-8 animate-spin text-gray-500" />
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="flex w-full items-center justify-center p-8 text-red-500"> <div className="flex h-96 w-full flex-col items-center justify-center text-red-500">
<p>Error: {error}</p> <p>Error loading shortcuts: {error}</p>
</div> <button
); onClick={() => window.location.reload()}
} className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
if (links.length === 0) { Retry
return ( </button>
<div className="flex w-full flex-col items-center justify-center p-12 text-gray-500">
<p className="mb-4 text-xl">No short links found</p>
<p>Create your first short URL to get started</p>
</div> </div>
); );
} }
@@ -289,11 +295,10 @@ export default function LinksPage() {
type="text" type="text"
placeholder="Search links..." placeholder="Search links..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={handleSearchChange}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
setCurrentPage(1); // Reset to page 1 when searching setCurrentPage(1); // Reset to page 1 when searching
// Trigger search via useEffect dependency change
} }
}} }}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
@@ -327,21 +332,26 @@ export default function LinksPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{filteredLinks.map(link => { {links.map(link => {
const metadata = getLinkMetadata(link); const metadata = getLinkMetadata(link);
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"> <tr key={link.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-6 py-4"> <td className="px-6 py-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-blue-600">{metadata.title}</span> <span className="font-medium text-gray-900">{metadata.title}</span>
<span className="text-xs text-gray-500">{shortUrl}</span> <span className="text-xs text-blue-500">{shortUrl}</span>
</div> </div>
</td> </td>
<td className="max-w-xs truncate px-6 py-4 text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500">
<a href={metadata.originalUrl} target="_blank" rel="noopener noreferrer" className="flex items-center hover:text-blue-500"> <a
{metadata.originalUrl} href={metadata.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-blue-500"
>
<span className="max-w-xs truncate">{metadata.originalUrl}</span>
<ExternalLink className="ml-1 h-3 w-3" /> <ExternalLink className="ml-1 h-3 w-3" />
</a> </a>
</td> </td>
@@ -465,7 +475,7 @@ export default function LinksPage() {
</div> </div>
)} )}
{filteredLinks.length === 0 && links.length > 0 && ( {links.length === 0 && (
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500"> <div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">
No links match your search criteria No links match your search criteria
</div> </div>