feat: add SharePanel component for social sharing with QR code support
- 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:
@@ -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
|
||||
|
||||
@@ -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 Search、Perplexity、Copilot/Bing、Gemini、Claude
|
||||
等 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>
|
||||
))
|
||||
|
||||
@@ -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 Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user