feat: add SharePanel component for social sharing with QR code support
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped

- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
This commit is contained in:
2026-04-02 14:15:21 +08:00
parent a516be2e91
commit 3628a46ed1
53 changed files with 4390 additions and 91 deletions

View File

@@ -349,6 +349,8 @@ export interface AdminAnalyticsResponse {
recent_events: AnalyticsRecentEvent[]
providers_last_7d: AnalyticsProviderBucket[]
top_referrers: AnalyticsReferrerBucket[]
ai_referrers_last_7d: AnalyticsReferrerBucket[]
ai_discovery_page_views_last_7d: number
popular_posts: AnalyticsPopularPost[]
daily_activity: AnalyticsDailyBucket[]
}
@@ -409,6 +411,7 @@ export interface AdminSiteSettingsResponse {
media_r2_secret_access_key: string | null
seo_default_og_image: string | null
seo_default_twitter_handle: string | null
seo_wechat_share_qr_enabled: boolean
notification_webhook_url: string | null
notification_channel_type: 'webhook' | 'ntfy' | string
notification_comment_enabled: boolean
@@ -482,6 +485,7 @@ export interface SiteSettingsPayload {
mediaR2SecretAccessKey?: string | null
seoDefaultOgImage?: string | null
seoDefaultTwitterHandle?: string | null
seoWechatShareQrEnabled?: boolean
notificationWebhookUrl?: string | null
notificationChannelType?: 'webhook' | 'ntfy' | string | null
notificationCommentEnabled?: boolean

View File

@@ -1,4 +1,4 @@
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search, Sparkles } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -80,6 +80,31 @@ function formatDuration(value: number | null) {
return `${minutes}${restSeconds}`
}
function formatReferrerLabel(value: string) {
switch (value) {
case 'chatgpt-search':
return 'ChatGPT Search'
case 'perplexity':
return 'Perplexity'
case 'copilot-bing':
return 'Copilot / Bing'
case 'gemini':
return 'Gemini'
case 'claude':
return 'Claude'
case 'google':
return 'Google'
case 'duckduckgo':
return 'DuckDuckGo'
case 'kagi':
return 'Kagi'
case 'direct':
return 'Direct'
default:
return value
}
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -197,6 +222,11 @@ export function AnalyticsPage() {
icon: Clock3,
},
]
const aiDiscoveryShare =
data.content_overview.page_views_last_7d > 0
? (data.ai_discovery_page_views_last_7d / data.content_overview.page_views_last_7d) * 100
: 0
const aiDiscoveryTopSource = data.ai_referrers_last_7d[0]
return (
<div className="space-y-6">
@@ -241,6 +271,94 @@ export function AnalyticsPage() {
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<Card className="border-primary/15 bg-gradient-to-br from-primary/5 via-card to-card">
<CardContent className="flex flex-col gap-5 pt-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">AI </Badge>
<span className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</span>
</div>
<div className="text-3xl font-semibold tracking-tight">
{data.ai_discovery_page_views_last_7d}
</div>
<p className="text-sm leading-6 text-muted-foreground">
ChatGPT SearchPerplexityCopilot/BingGeminiClaude
AI / 访
</p>
</div>
<div className="grid gap-3 sm:min-w-72 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
page_view
</p>
<p className="mt-3 text-2xl font-semibold">{formatPercent(aiDiscoveryShare)}</p>
<p className="mt-2 text-sm text-muted-foreground">
page_view{data.content_overview.page_views_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-3 text-2xl font-semibold">
{aiDiscoveryTopSource ? formatReferrerLabel(aiDiscoveryTopSource.referrer) : '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{aiDiscoveryTopSource ? `${aiDiscoveryTopSource.count} 次访问` : '等待来源数据'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI
</CardTitle>
<CardDescription>
便 GEO AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.ai_referrers_last_7d.length ? (
data.ai_referrers_last_7d.map((item) => {
const width = `${
Math.max(
(item.count / Math.max(data.ai_discovery_page_views_last_7d, 1)) * 100,
8,
)
}%`
return (
<div key={item.referrer} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{formatReferrerLabel(item.referrer)}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})
) : (
<p className="text-sm text-muted-foreground">
7 AI
</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
@@ -491,7 +609,7 @@ export function AnalyticsPage() {
key={item.referrer}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="line-clamp-1 font-medium">{item.referrer}</span>
<span className="line-clamp-1 font-medium">{formatReferrerLabel(item.referrer)}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))

View File

@@ -6,6 +6,7 @@ import {
MessageSquareWarning,
RefreshCcw,
Rss,
Sparkles,
Star,
Tags,
Workflow,
@@ -37,7 +38,7 @@ import {
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types'
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
function StatCard({
label,
@@ -66,9 +67,35 @@ function StatCard({
)
}
function formatAiSourceLabel(value: string) {
switch (value) {
case 'chatgpt-search':
return 'ChatGPT Search'
case 'perplexity':
return 'Perplexity'
case 'copilot-bing':
return 'Copilot / Bing'
case 'gemini':
return 'Gemini'
case 'claude':
return 'Claude'
case 'google':
return 'Google'
case 'duckduckgo':
return 'DuckDuckGo'
case 'kagi':
return 'Kagi'
case 'direct':
return 'Direct'
default:
return value
}
}
export function DashboardPage() {
const [data, setData] = useState<AdminDashboardResponse | null>(null)
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -78,13 +105,15 @@ export function DashboardPage() {
setRefreshing(true)
}
const [next, nextWorkerOverview] = await Promise.all([
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(),
adminApi.getWorkersOverview(),
adminApi.analytics(),
])
startTransition(() => {
setData(next)
setWorkerOverview(nextWorkerOverview)
setAnalytics(nextAnalytics)
})
if (showToast) {
@@ -105,7 +134,7 @@ export function DashboardPage() {
void loadDashboard(false)
}, [loadDashboard])
if (loading || !data || !workerOverview) {
if (loading || !data || !workerOverview || !analytics) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@@ -113,7 +142,10 @@ export function DashboardPage() {
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<Skeleton className="h-[420px] rounded-3xl" />
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
<Skeleton className="h-[420px] rounded-3xl" />
<Skeleton className="h-[420px] rounded-3xl" />
</div>
</div>
)
}
@@ -160,6 +192,12 @@ export function DashboardPage() {
icon: Workflow,
},
]
const aiTrafficShare =
analytics.content_overview.page_views_last_7d > 0
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100
: 0
const topAiSource = analytics.ai_referrers_last_7d[0]
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length
return (
<div className="space-y-6">
@@ -328,6 +366,73 @@ export function DashboardPage() {
</p>
</div>
<div className="rounded-2xl border border-primary/20 bg-gradient-to-br from-primary/8 via-background/90 to-background/70 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
GEO / AI
</p>
<p className="mt-3 text-3xl font-semibold">{analytics.ai_discovery_page_views_last_7d}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
7 ChatGPT SearchPerplexityCopilot/BingGeminiClaude 访
</p>
</div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Sparkles className="h-5 w-5" />
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">访</div>
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div>
<div className="mt-1 text-xs text-muted-foreground"> 7 page_view</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground"></div>
<div className="mt-2 text-base font-semibold">
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
<div className="mt-1 text-xs text-muted-foreground"> AI </div>
</div>
</div>
{analytics.ai_referrers_last_7d.length ? (
<div className="mt-4 space-y-3">
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
const width = `${Math.max(
(item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100,
8,
)}%`
return (
<div key={item.referrer} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span>
<span className="text-muted-foreground">{item.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})}
</div>
) : (
<p className="mt-4 text-sm text-muted-foreground"> 7 AI </p>
)}
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start justify-between gap-4">
<div>

View File

@@ -211,6 +211,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
seoDefaultOgImage: form.seo_default_og_image,
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
seoWechatShareQrEnabled: form.seo_wechat_share_qr_enabled,
notificationWebhookUrl: form.notification_webhook_url,
notificationChannelType: form.notification_channel_type,
notificationCommentEnabled: form.notification_comment_enabled,
@@ -857,6 +858,24 @@ export function SiteSettingsPage() {
}
/>
</Field>
<div className="lg:col-span-2">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.seo_wechat_share_qr_enabled}
onChange={(event) =>
updateField('seo_wechat_share_qr_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
便
</p>
</div>
</label>
</div>
<div className="grid gap-4 lg:col-span-2 md:grid-cols-[220px_minmax(0,1fr)]">
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
<Select