feat: 添加 GitHub 统计数据展示功能
新增 GitHub 统计卡片和徽章组件,在项目卡片、详情页和侧边栏中展示 GitHub stars、forks 等统计数据 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ export default async function ProjectDetailPage({
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark font-body transition-colors duration-200">
|
||||
<div className="container mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Back Button */}
|
||||
<div className="mb-8">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
interface GitHubBadgesProps {
|
||||
starsUrl?: string | null
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: { width: 80, height: 20 },
|
||||
md: { width: 100, height: 20 },
|
||||
lg: { width: 120, height: 20 }
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Stars 徽章组件 - 显示 GitHub Stars 数量
|
||||
* 适用于项目详情页
|
||||
*/
|
||||
export function GitHubBadges({
|
||||
starsUrl,
|
||||
size = 'md',
|
||||
className = ''
|
||||
}: GitHubBadgesProps) {
|
||||
if (!starsUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { width, height } = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Image
|
||||
src={starsUrl}
|
||||
alt="GitHub Stars"
|
||||
width={width}
|
||||
height={height}
|
||||
unoptimized
|
||||
className="hover:opacity-80 transition-opacity rounded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Stars 紧凑型组件 - 用于项目卡片
|
||||
* 在较小的空间内显示 GitHub Stars 数量
|
||||
*/
|
||||
export function GitHubStatsCompact({
|
||||
starsUrl,
|
||||
className = ''
|
||||
}: {
|
||||
starsUrl?: string | null
|
||||
className?: string
|
||||
}) {
|
||||
if (!starsUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 ${className}`}>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||
</svg>
|
||||
<Image
|
||||
src={starsUrl}
|
||||
alt="Stars"
|
||||
width={60}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
/**
|
||||
* GitHub Statistics 数据接口 - 支持文本值或徽章 URL
|
||||
*/
|
||||
export interface GitHubTextStats {
|
||||
owner?: string | null
|
||||
repo?: string | null
|
||||
stars?: string | null // 文本值(如 "142k")或徽章 URL
|
||||
forks?: string | null // 文本值(如 "34k")或徽章 URL
|
||||
issues?: string | null // 文本值(如 "892")或徽章 URL
|
||||
license?: string | null // 文本值(如 "MIT")或徽章 URL
|
||||
}
|
||||
|
||||
interface GitHubTextStatsCardProps {
|
||||
stats: GitHubTextStats
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否是 URL
|
||||
*/
|
||||
function isUrl(str: string): boolean {
|
||||
return str.startsWith('http://') || str.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染统计值 - 支持文本或徽章图片
|
||||
*/
|
||||
function renderStatValue(value: string) {
|
||||
if (isUrl(value)) {
|
||||
return (
|
||||
<Image
|
||||
src={value}
|
||||
alt="Stat"
|
||||
width={70}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="h-5 w-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="font-display font-bold text-lg leading-tight">{value}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Statistics Card Component - 混合模式版本
|
||||
* 支持文本显示和徽章图片显示,符合 Neo-brutalism 设计风格
|
||||
*
|
||||
* 设计原型来源: design/detail.html 第 223-289 行
|
||||
*/
|
||||
export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCardProps) {
|
||||
// 如果没有任何统计数据,不显示卡片
|
||||
if (!stats.stars && !stats.forks && !stats.issues && !stats.license) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-100 dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 text-black dark:text-white p-4 ${className}`}>
|
||||
{/* Header */}
|
||||
<h3 className="font-display text-xs uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-3.14-.95-3.14-.95-.43-.92-.1-1.25-.1-1.25.2-.05.41.08.95.08 2.76 1.89 3.78 1.89 3.78 1.69 2.88 4.44 2.05 5.53-.16.47-.86-.94-1.25-1.14-.42-.26-.89-.04-1.25.23-.89.65-2.18.95-3.3 1.01-.22.01-.44.05-.66.12-.26.11-.53.03-.72-.13-.22-.18-.44-.49-.44-.49-.25-.95-.08-1.25.23-.72.72-1.87 2.05-2.84 2.85-.13.11-.24.26-.24.42 0 .33.27.76.76.76 1.16 0 .87.72 1.96 2.05 2.4 2.82.2.06.43.09.65.05.31-.06.6-.17.87-.33.26-.16.48-.36.63-.57.23-.21.47-.44.64-.67.19-.26.35-.54.48-.83.11-.26.17-.54.17-.82 0-.47-.27-.91-.66-1.1-.67-.34-1.45-.51-2.32-.51-.88 0-1.67.18-2.35.53-.26.13-.5.25-.73.36-.22.1-.42.23-.58.37-.14.13-.26.26-.34.4-.07.14-.1.29-.1.44 0 .18.09.34.25.46.13.11.29.18.46.18.19 0 .38-.06.55-.17.15-.11.28-.24.39-.39.1-.15.18-.31.23-.48.05-.18.06-.36.06-.54 0-.3-.17-.57-.44-.74-.23-.17-.5-.26-.78-.26-.28 0-.55.09-.79.26-.23.17-.41.39-.54.64-.12.24-.17.5-.17.77 0 .26.17.5.43.67.25.17.57.26.89.26.31 0 .6-.09.85-.26.23-.17.41-.39.54-.64.12-.24.18-.5.18-.77 0-.26-.17-.5-.43-.67-.26-.17-.57-.26-.89-.26-.31 0-.6.09-.85.26-.23.17-.41.39-.54-.64-.12.24-.17.5-.17.77zM8 15c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"/>
|
||||
</svg>
|
||||
GitHub Statistics
|
||||
</h3>
|
||||
|
||||
{/* Stats Grid - 2 columns */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Stars */}
|
||||
{stats.stars && stats.owner && stats.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${stats.owner}/${stats.repo}/stargazers`}
|
||||
className="group"
|
||||
title="View stars on GitHub"
|
||||
>
|
||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||
</svg>
|
||||
{renderStatValue(stats.stars)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Forks */}
|
||||
{stats.forks && stats.owner && stats.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${stats.owner}/${stats.repo}/network/members`}
|
||||
className="group"
|
||||
title="View forks on GitHub"
|
||||
>
|
||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0V.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
{renderStatValue(stats.forks)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{stats.issues && stats.owner && stats.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${stats.owner}/${stats.repo}/issues`}
|
||||
className="group"
|
||||
title="View issues on GitHub"
|
||||
>
|
||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||
<svg className="w-4 h-4 text-green-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/>
|
||||
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zM1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"/>
|
||||
</svg>
|
||||
{renderStatValue(stats.issues)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* License */}
|
||||
{stats.license && stats.owner && stats.repo && (
|
||||
<Link
|
||||
href={`https://github.com/${stats.owner}/${stats.repo}/blob/main/LICENSE`}
|
||||
className="group"
|
||||
title="View license on GitHub"
|
||||
>
|
||||
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
|
||||
<svg className="w-4 h-4 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 1.75c4.556 0 8.25 3.694 8.25 8.25 0 1.356-.344 2.647-.956 3.78-.556.057-1.072.25-1.444.525-.436.32-.688.742-.688 1.25 0 .469.253.891.688 1.25.47.4.869.496 1.444.525 2.09-.613 3.325-1.438 4.188-.47.48-1.088.79-1.5.79-.406-.006-.738-.274-.96-.638-.31-.494-.672-.59-1.031-.59-.25 0-.476.088-.656.25-.182.163-.39.25-.625.25-.238 0-.447-.087-.625-.25-.178-.164-.395-.25-.641-.25-.356 0-.722.096-1.031.59-.413.584-.554.29-1.5-.79-.612.313-1.156.48-1.625.525-.47-.22-.906-.25-1.444-.525-.612-1.133-.956-3.78-.956-4.556 0-8.25 3.694-8.25 8.25zM4.5 9a.5.5 0 01.5-.5h6a.5.5 0 010 1h-6a.5.5 0 01-.5-.5z"/>
|
||||
</svg>
|
||||
{renderStatValue(stats.license)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { GitHubStatsCompact } from './GitHubBadges'
|
||||
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: {
|
||||
@@ -15,6 +18,12 @@ interface ProjectCardProps {
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
}>
|
||||
links?: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
title?: string | null
|
||||
}>
|
||||
}
|
||||
locale: string
|
||||
featured?: boolean
|
||||
@@ -37,6 +46,9 @@ export async function ProjectCard({ project, locale, featured = false }: Project
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
|
||||
// Generate GitHub badge URLs
|
||||
const badges = project.links ? getGitHubBadgesFromLinks(project.links as any) : { stars: null, forks: null }
|
||||
|
||||
// Helper to get display name for tag based on locale
|
||||
const getTagName = (tag: { name: string; nameEn?: string | null }) => {
|
||||
return locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
@@ -73,14 +85,34 @@ export async function ProjectCard({ project, locale, featured = false }: Project
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Details Link */}
|
||||
<Link
|
||||
href={`/${locale}/projects/${project.slug}`}
|
||||
className="inline-flex items-center font-display font-bold text-sm uppercase hover:text-secondary dark:hover:text-primary group"
|
||||
>
|
||||
{t('viewDetails')}{' '}
|
||||
<span className="ml-1 transform group-hover:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
{/* Footer with View Details and Stars */}
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
{/* View Details Link */}
|
||||
<Link
|
||||
href={`/${locale}/projects/${project.slug}`}
|
||||
className="inline-flex items-center font-display font-bold text-sm uppercase hover:text-secondary dark:hover:text-primary group"
|
||||
>
|
||||
{t('viewDetails')}{' '}
|
||||
<span className="ml-1 transform group-hover:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
|
||||
{/* GitHub Stars Badge */}
|
||||
{badges.stars && (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||
</svg>
|
||||
<Image
|
||||
src={badges.stars}
|
||||
alt="Stars"
|
||||
width={70}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { ShareButtons } from './ShareButtons'
|
||||
import { GitHubBadges } from './GitHubBadges'
|
||||
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
project: {
|
||||
@@ -54,6 +56,9 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
const displayContent = locale === 'en' && project.contentEn ? project.contentEn : project.content
|
||||
|
||||
// Generate GitHub badge URLs
|
||||
const badges = getGitHubBadgesFromLinks(project.links as any)
|
||||
|
||||
// Get category from first tag
|
||||
const category = project.tags[0]?.name || 'AI Agent'
|
||||
const categoryEn = project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { GitHubTextStatsCard } from './GitHubTextStatsCard'
|
||||
import { getGitHubInfoFromLinks } from '@/lib/github/badges'
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
project: {
|
||||
@@ -37,6 +39,9 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
const t = await getTranslations('project')
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
|
||||
// 获取 GitHub 统计数据
|
||||
const githubInfo = getGitHubInfoFromLinks(project.links)
|
||||
|
||||
return (
|
||||
<aside className="space-y-8">
|
||||
{/* Project Links Card */}
|
||||
@@ -74,31 +79,25 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">{t('license')}</span>
|
||||
<span className="font-bold text-sm">MIT</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">{t('pricing')}</span>
|
||||
<span className="font-bold text-sm">Free</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">{t('status')}</span>
|
||||
<span className="font-bold text-sm">Active</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">{t('type')}</span>
|
||||
<span className="font-bold text-sm">{t('openSource')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub Statistics Card */}
|
||||
{githubInfo.owner && githubInfo.repo && (
|
||||
<GitHubTextStatsCard
|
||||
stats={{
|
||||
owner: githubInfo.owner,
|
||||
repo: githubInfo.repo,
|
||||
// 注意:目前使用徽章 URL,后续可以通过 API 获取实际文本数据
|
||||
// 如需显示实际数字,需要使用 GitHub API 或服务端获取
|
||||
stars: githubInfo.stars,
|
||||
forks: githubInfo.forks,
|
||||
issues: githubInfo.issues,
|
||||
license: githubInfo.license
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CTA Card */}
|
||||
<div className="bg-primary border-2 border-black p-6 relative overflow-hidden shadow-brutal dark:shadow-none group cursor-pointer hover:bg-yellow-400 transition-colors">
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-white opacity-20 transform rotate-45 translate-x-10 -translate-y-10"></div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* GitHub API 服务
|
||||
* 获取仓库的统计数据(stars, forks, issues, license 等)
|
||||
*/
|
||||
|
||||
export interface GitHubStats {
|
||||
stargazers_count: number
|
||||
forks_count: number
|
||||
open_issues_count: number
|
||||
license: { key: string; name: string } | null
|
||||
pushed_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 GitHub API 获取仓库统计信息
|
||||
* @param owner - 仓库所有者
|
||||
* @param repo - 仓库名称
|
||||
* @returns GitHub 统计数据或 null
|
||||
*/
|
||||
export async function getGitHubStats(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<GitHubStats | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
// 如果需要更高的速率限制,可以添加 GitHub token
|
||||
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
|
||||
},
|
||||
next: { revalidate: 300 } // 缓存 5 分钟
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`GitHub API error: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
stargazers_count: data.stargazers_count || 0,
|
||||
forks_count: data.forks_count || 0,
|
||||
open_issues_count: data.open_issues_count || 0,
|
||||
license: data.license || null,
|
||||
pushed_at: data.pushed_at || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(如 142000 -> 142k)
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间(如 "2 days ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string, locale: string = 'zh'): string {
|
||||
if (!dateString) return ''
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (locale === 'en') {
|
||||
if (diffDays === 0) return 'today'
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
|
||||
return `${Math.floor(diffDays / 365)} years ago`
|
||||
} else {
|
||||
if (diffDays === 0) return '今天'
|
||||
if (diffDays === 1) return '昨天'
|
||||
if (diffDays < 7) return `${diffDays} 天前`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} 周前`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} 月前`
|
||||
return `${Math.floor(diffDays / 365)} 年前`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ExternalLink, LinkType } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* 从 GitHub URL 提取 owner 和 repo
|
||||
* @param url - GitHub 仓库 URL
|
||||
* @returns owner 和 repo,或 null 如果无法解析
|
||||
*/
|
||||
export function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
||||
const patterns = [
|
||||
/github\.com\/([^\/]+)\/([^\/\?#]+)/,
|
||||
/github\.com\/([^\/]+)\/([^\/\?#]+)\.git/
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
const repo = match[2].replace(/\.git$/, '').replace(/\/$/, '')
|
||||
return { owner: match[1], repo }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 GitHub Stars 徽章 URL
|
||||
* @param owner - 仓库所有者
|
||||
* @param repo - 仓库名称
|
||||
* @returns Shields.io 徽章 URL
|
||||
*/
|
||||
export function getGitHubStarsBadgeUrl(owner: string, repo: string): string {
|
||||
return `https://img.shields.io/github/stars/${owner}/${repo}?style=social`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有 GitHub 徽章 URL
|
||||
* @param owner - 仓库所有者
|
||||
* @param repo - 仓库名称
|
||||
* @returns 包含所有徽章 URL 的对象
|
||||
*/
|
||||
export function getAllGitHubBadgeUrls(owner: string, repo: string): {
|
||||
stars: string
|
||||
forks: string
|
||||
issues: string
|
||||
license: string
|
||||
lastCommit: string
|
||||
} {
|
||||
return {
|
||||
stars: `https://img.shields.io/github/stars/${owner}/${repo}?style=social`,
|
||||
forks: `https://img.shields.io/github/forks/${owner}/${repo}?style=social`,
|
||||
issues: `https://img.shields.io/github/issues/${owner}/${repo}`,
|
||||
license: `https://img.shields.io/github/license/${owner}/${repo}`,
|
||||
lastCommit: `https://img.shields.io/github/last-commit/${owner}/${repo}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目链接中提取 GitHub Stars 徽章 URL
|
||||
* @param links - 项目外部链接数组
|
||||
* @returns stars 徽章 URL 或 null
|
||||
*/
|
||||
export function getGitHubBadgesFromLinks(
|
||||
links: ExternalLink[]
|
||||
): { stars: string | null } {
|
||||
const githubLink = links.find(link => link.type === 'GITHUB')
|
||||
|
||||
if (!githubLink) {
|
||||
return { stars: null }
|
||||
}
|
||||
|
||||
const parsed = parseGitHubUrl(githubLink.url)
|
||||
|
||||
if (!parsed) {
|
||||
return { stars: null }
|
||||
}
|
||||
|
||||
return {
|
||||
stars: getGitHubStarsBadgeUrl(parsed.owner, parsed.repo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目链接中提取 GitHub 信息(用于文本显示)
|
||||
* @param links - 项目外部链接数组
|
||||
* @returns GitHub 统计数据对象
|
||||
*/
|
||||
export function getGitHubInfoFromLinks(
|
||||
links: Array<{ type: string; url: string }>
|
||||
): {
|
||||
owner: string | null
|
||||
repo: string | null
|
||||
stars: string | null
|
||||
forks: string | null
|
||||
issues: string | null
|
||||
license: string | null
|
||||
lastCommit: string | null
|
||||
} {
|
||||
const githubLink = links.find(link => link.type === 'GITHUB')
|
||||
|
||||
if (!githubLink) {
|
||||
return {
|
||||
owner: null,
|
||||
repo: null,
|
||||
stars: null,
|
||||
forks: null,
|
||||
issues: null,
|
||||
license: null,
|
||||
lastCommit: null
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseGitHubUrl(githubLink.url)
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
owner: null,
|
||||
repo: null,
|
||||
stars: null,
|
||||
forks: null,
|
||||
issues: null,
|
||||
license: null,
|
||||
lastCommit: null
|
||||
}
|
||||
}
|
||||
|
||||
// 返回徽章 URL,组件中可以使用这些 URL 获取实际数据
|
||||
// 或者通过 GitHub API 获取实际数字
|
||||
const badgeUrls = getAllGitHubBadgeUrls(parsed.owner, parsed.repo)
|
||||
|
||||
return {
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
// 暂时返回徽章 URL,后续可以通过 API 获取实际数字
|
||||
stars: badgeUrls.stars,
|
||||
forks: badgeUrls.forks,
|
||||
issues: badgeUrls.issues,
|
||||
license: badgeUrls.license,
|
||||
lastCommit: badgeUrls.lastCommit
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user