From 3628a46ed1c74444eaab80ab7675f1f10d10409f Mon Sep 17 00:00:00 2001 From: limitcool Date: Thu, 2 Apr 2026 14:15:21 +0800 Subject: [PATCH] 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. --- .gitea/workflows/backend-docker.yml | 195 +++- README.md | 11 + admin/src/lib/types.ts | 4 + admin/src/pages/analytics-page.tsx | 122 ++- admin/src/pages/dashboard-page.tsx | 113 ++- admin/src/pages/site-settings-page.tsx | 19 + backend/Dockerfile | 22 +- backend/migration/src/lib.rs | 2 + ...echat_share_qr_setting_to_site_settings.rs | 52 + backend/src/controllers/admin_api.rs | 2 + backend/src/controllers/site_settings.rs | 8 + backend/src/models/_entities/site_settings.rs | 1 + backend/src/services/analytics.rs | 125 ++- deploy/docker/.env.example | 4 + deploy/docker/compose.package.yml | 1 + frontend/README.md | 24 + frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 148 +++ frontend/scripts/submit-indexnow.mjs | 134 +++ .../src/components/seo/DiscoveryBrief.astro | 86 ++ .../src/components/seo/PageViewTracker.astro | 89 ++ frontend/src/components/seo/SharePanel.astro | 630 +++++++++++++ frontend/src/layouts/BaseLayout.astro | 34 + frontend/src/lib/api/client.ts | 5 + frontend/src/lib/seo.ts | 276 ++++++ frontend/src/lib/types/index.ts | 3 + frontend/src/pages/about/index.astro | 114 ++- frontend/src/pages/articles/[slug].astro | 888 +++++++++++++++++- frontend/src/pages/articles/index.astro | 105 ++- frontend/src/pages/ask/index.astro | 100 ++ frontend/src/pages/categories/[slug].astro | 78 +- frontend/src/pages/categories/index.astro | 83 +- frontend/src/pages/friends/index.astro | 121 ++- frontend/src/pages/index.astro | 91 +- frontend/src/pages/indexnow-key.txt.ts | 28 + frontend/src/pages/llms-full.txt.ts | 94 ++ frontend/src/pages/llms.txt.ts | 77 ++ frontend/src/pages/reviews/[id].astro | 54 +- frontend/src/pages/reviews/index.astro | 142 ++- frontend/src/pages/robots.txt.ts | 17 + frontend/src/pages/sitemap.xml.ts | 37 +- .../src/pages/subscriptions/confirm.astro | 2 +- frontend/src/pages/subscriptions/manage.astro | 2 +- .../src/pages/subscriptions/unsubscribe.astro | 2 +- frontend/src/pages/tags/[slug].astro | 78 +- frontend/src/pages/tags/index.astro | 83 +- frontend/src/pages/timeline/index.astro | 84 +- package.json | 1 + playwright-smoke/README.md | 3 +- playwright-smoke/mock-server.mjs | 15 +- playwright-smoke/tests/admin.spec.ts | 5 + playwright-smoke/tests/frontend.spec.ts | 49 +- playwright-smoke/tests/helpers.ts | 14 + 53 files changed, 4390 insertions(+), 91 deletions(-) create mode 100644 backend/migration/src/m20260402_000037_add_wechat_share_qr_setting_to_site_settings.rs create mode 100644 frontend/scripts/submit-indexnow.mjs create mode 100644 frontend/src/components/seo/DiscoveryBrief.astro create mode 100644 frontend/src/components/seo/PageViewTracker.astro create mode 100644 frontend/src/components/seo/SharePanel.astro create mode 100644 frontend/src/lib/seo.ts create mode 100644 frontend/src/pages/indexnow-key.txt.ts create mode 100644 frontend/src/pages/llms-full.txt.ts create mode 100644 frontend/src/pages/llms.txt.ts diff --git a/.gitea/workflows/backend-docker.yml b/.gitea/workflows/backend-docker.yml index 2d215cf..a16bacf 100644 --- a/.gitea/workflows/backend-docker.yml +++ b/.gitea/workflows/backend-docker.yml @@ -18,25 +18,130 @@ permissions: packages: write jobs: + resolve-build-targets: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + count: ${{ steps.targets.outputs.count }} + frontend_changed: ${{ steps.targets.outputs.frontend_changed }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve build targets + id: targets + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + CURRENT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + declare -A SELECTED=() + BUILD_ALL=0 + + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + BUILD_ALL=1 + fi + + BEFORE="${BEFORE_SHA:-}" + if [ "${BUILD_ALL}" -ne 1 ] && { [ -z "${BEFORE}" ] || printf '%s' "${BEFORE}" | grep -Eq '^0+$'; }; then + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BEFORE="$(git rev-parse HEAD^)" + else + BUILD_ALL=1 + fi + fi + + CHANGED_FILES="" + if [ "${BUILD_ALL}" -ne 1 ]; then + CHANGED_FILES="$(git diff --name-only "${BEFORE}" "${CURRENT_SHA}" || true)" + fi + + while IFS= read -r path; do + [ -n "${path}" ] || continue + + case "${path}" in + backend/*) + SELECTED[backend]=1 + ;; + frontend/*) + SELECTED[frontend]=1 + ;; + admin/*) + SELECTED[admin]=1 + ;; + deploy/docker/*|.gitea/workflows/backend-docker.yml) + BUILD_ALL=1 + ;; + esac + done <<< "${CHANGED_FILES}" + + if [ "${BUILD_ALL}" -eq 1 ] || [ "${#SELECTED[@]}" -eq 0 ]; then + SELECTED[backend]=1 + SELECTED[frontend]=1 + SELECTED[admin]=1 + fi + + COMPONENTS=() + [ -n "${SELECTED[backend]:-}" ] && COMPONENTS+=(backend) + [ -n "${SELECTED[frontend]:-}" ] && COMPONENTS+=(frontend) + [ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin) + + COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")" + export COMPONENTS_CSV + + python <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + mapping = { + "backend": { + "component": "backend", + "dockerfile": "backend/Dockerfile", + "context": "backend", + "default_image_name": "termi-astro-backend", + }, + "frontend": { + "component": "frontend", + "dockerfile": "frontend/Dockerfile", + "context": "frontend", + "default_image_name": "termi-astro-frontend", + }, + "admin": { + "component": "admin", + "dockerfile": "admin/Dockerfile", + "context": "admin", + "default_image_name": "termi-astro-admin", + }, + } + + components = [item for item in os.environ.get("COMPONENTS_CSV", "").split(",") if item] + matrix = {"include": [mapping[item] for item in components]} + + print(f"matrix={json.dumps(matrix, separators=(',', ':'))}") + print(f"count={len(components)}") + print(f"frontend_changed={'true' if 'frontend' in components else 'false'}") + PY + + echo "Selected components: ${COMPONENTS_CSV}" + if [ -n "${CHANGED_FILES}" ]; then + echo "Changed files:" + printf '%s\n' "${CHANGED_FILES}" + else + echo "Changed files: " + fi + build-and-push: + needs: resolve-build-targets + if: needs.resolve-build-targets.outputs.count != '0' runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 - matrix: - include: - - component: backend - dockerfile: backend/Dockerfile - context: backend - default_image_name: termi-astro-backend - - component: frontend - dockerfile: frontend/Dockerfile - context: frontend - default_image_name: termi-astro-frontend - - component: admin - dockerfile: admin/Dockerfile - context: admin - default_image_name: termi-astro-admin + matrix: ${{ fromJson(needs.resolve-build-targets.outputs.matrix) }} steps: - name: Checkout @@ -103,6 +208,8 @@ jobs: echo "tag_latest=latest" echo "tag_branch=${SAFE_REF}" echo "tag_sha=${SHORT_SHA}" + echo "cache_ref_branch=${IMAGE_BASE}:buildcache-${SAFE_REF}" + echo "cache_ref_shared=${IMAGE_BASE}:buildcache" echo "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}" echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}" echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}" @@ -200,6 +307,8 @@ jobs: TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} TAG_SHA: ${{ steps.meta.outputs.tag_sha }} + CACHE_REF_BRANCH: ${{ steps.meta.outputs.cache_ref_branch }} + CACHE_REF_SHARED: ${{ steps.meta.outputs.cache_ref_shared }} FRONTEND_PUBLIC_API_BASE_URL: ${{ steps.meta.outputs.frontend_public_api_base_url }} ADMIN_VITE_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }} ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }} @@ -222,8 +331,12 @@ jobs: --file "${DOCKERFILE}" \ "${BUILD_ARGS[@]}" \ --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "type=registry,ref=${CACHE_REF_BRANCH}" \ + --cache-from "type=registry,ref=${CACHE_REF_SHARED}" \ --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_BRANCH}" \ --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \ + --cache-to "type=registry,ref=${CACHE_REF_BRANCH},mode=max" \ + --cache-to "type=registry,ref=${CACHE_REF_SHARED},mode=max" \ --cache-to "type=inline" \ --tag "${IMAGE_BASE}:${TAG_LATEST}" \ --tag "${IMAGE_BASE}:${TAG_BRANCH}" \ @@ -244,3 +357,57 @@ jobs: echo "- ${IMAGE_BASE}:${TAG_LATEST}" echo "- ${IMAGE_BASE}:${TAG_BRANCH}" echo "- ${IMAGE_BASE}:${TAG_SHA}" + + submit-indexnow: + needs: + - resolve-build-targets + - build-and-push + if: needs.resolve-build-targets.outputs.frontend_changed == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Submit IndexNow (optional) + shell: bash + env: + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + SITE_URL: ${{ vars.INDEXNOW_SITE_URL }} + PUBLIC_API_BASE_URL: ${{ vars.INDEXNOW_PUBLIC_API_BASE_URL }} + GITHUB_REF_NAME_VALUE: ${{ github.ref_name }} + run: | + set -euo pipefail + + REF_NAME="${GITHUB_REF_NAME_VALUE:-${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}}" + if [ "${GITHUB_EVENT_NAME:-${GITEA_EVENT_NAME:-}}" != "push" ]; then + echo "Current event is not push, skip IndexNow submission." + exit 0 + fi + + if [ "${REF_NAME}" != "main" ] && [ "${REF_NAME}" != "master" ]; then + echo "Current ref '${REF_NAME}' is not main/master, skip IndexNow submission." + exit 0 + fi + + if [ -z "${INDEXNOW_KEY:-}" ]; then + echo "Missing INDEXNOW_KEY secret, skip IndexNow submission." + exit 0 + fi + + if [ -z "${SITE_URL:-}" ]; then + echo "Missing INDEXNOW_SITE_URL variable, skip IndexNow submission." + exit 0 + fi + + pnpm --dir frontend run indexnow:submit diff --git a/README.md b/README.md index 43473ab..11eda4d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en - Secrets - `REGISTRY_USERNAME` - `REGISTRY_TOKEN` + - `INDEXNOW_KEY`(可选;如果要在主分支镜像发布后自动提交 IndexNow) - Variables(可选) - `REGISTRY_HOST`(默认 `git.init.cool`) - `IMAGE_NAMESPACE`(默认仓库 owner) @@ -153,6 +154,16 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en - `ADMIN_VITE_API_BASE`(admin 镜像构建注入的 API 默认地址,默认 `http://localhost:5150`;运行时可被 `ADMIN_API_BASE_URL` 覆盖) - `ADMIN_VITE_FRONTEND_BASE_URL`(admin 镜像构建注入的前台跳转默认基址,默认 `http://localhost:4321`;运行时可被 `ADMIN_FRONTEND_BASE_URL` 覆盖) - `ADMIN_VITE_BASENAME`(可选;如果 admin 要挂在 `/admin` 这类路径前缀下,构建时设置为 `/admin`) + - `INDEXNOW_SITE_URL`(可选;自动提交 IndexNow 时使用的前台 canonical 域名,例如 `https://blog.init.cool`) + - `INDEXNOW_PUBLIC_API_BASE_URL`(可选;如果站点公开 API 不是 `${INDEXNOW_SITE_URL}/api`,可在这里显式指定) + +如果同时配置了 `INDEXNOW_KEY` + `INDEXNOW_SITE_URL`,主分支镜像发布成功后会自动执行一次: + +```powershell +pnpm --dir frontend run indexnow:submit +``` + +用来把首页、文章、分类、标签、评测等 canonical URL 提交到 IndexNow。 ### MCP Server diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 878effe..6d0abf8 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -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 diff --git a/admin/src/pages/analytics-page.tsx b/admin/src/pages/analytics-page.tsx index 4ac3f49..f4c9f6a 100644 --- a/admin/src/pages/analytics-page.tsx +++ b/admin/src/pages/analytics-page.tsx @@ -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(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 (
@@ -241,6 +271,94 @@ export function AnalyticsPage() { ))}
+
+ + +
+
+ AI 来源流量 + + 最近 7 天 + +
+
+ {data.ai_discovery_page_views_last_7d} +
+

+ 来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude + 等 AI / 答案引擎的页面访问。 +

+
+ +
+
+

+ 占全部 page_view +

+

{formatPercent(aiDiscoveryShare)}

+

+ 总 page_view:{data.content_overview.page_views_last_7d} +

+
+
+

+ 最高来源 +

+

+ {aiDiscoveryTopSource ? formatReferrerLabel(aiDiscoveryTopSource.referrer) : '暂无'} +

+

+ {aiDiscoveryTopSource ? `${aiDiscoveryTopSource.count} 次访问` : '等待来源数据'} +

+
+
+
+
+ + + + + + AI 来源明细 + + + 便于判断 GEO 改造后,哪些 AI 搜索或答案引擎真正把流量带回来了。 + + + + {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 ( +
+
+ {formatReferrerLabel(item.referrer)} + {item.count} +
+
+
+
+
+ ) + }) + ) : ( +

+ 最近 7 天还没有识别到 AI 搜索来源流量。 +

+ )} + + +
+
@@ -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" > - {item.referrer} + {formatReferrerLabel(item.referrer)} {item.count}
)) diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx index 36672bd..fd13070 100644 --- a/admin/src/pages/dashboard-page.tsx +++ b/admin/src/pages/dashboard-page.tsx @@ -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(null) const [workerOverview, setWorkerOverview] = useState(null) + const [analytics, setAnalytics] = useState(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 (
@@ -113,7 +142,10 @@ export function DashboardPage() { ))}
- +
+ + +
) } @@ -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 (
@@ -328,6 +366,73 @@ export function DashboardPage() {

+
+
+
+

+ GEO / AI 来源概览 +

+

{analytics.ai_discovery_page_views_last_7d}

+

+ 最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。 +

+
+
+ +
+
+ +
+
+
访问占比
+
{Math.round(aiTrafficShare)}%
+
基于近 7 天全部 page_view
+
+
+
最高来源
+
+ {topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'} +
+
+ {topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'} +
+
+
+
已识别来源
+
{totalAiSourceBuckets}
+
当前已聚合的 AI 搜索渠道
+
+
+ + {analytics.ai_referrers_last_7d.length ? ( +
+ {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 ( +
+
+ {formatAiSourceLabel(item.referrer)} + {item.count} +
+
+
+
+
+ ) + })} +
+ ) : ( +

最近 7 天还没有识别到 AI 搜索来源流量。

+ )} +
+
diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index a760dac..eecd6f7 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -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() { } /> +
+ +