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:
@@ -18,25 +18,130 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
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: <build all>"
|
||||||
|
fi
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: resolve-build-targets
|
||||||
|
if: needs.resolve-build-targets.outputs.count != '0'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 1
|
max-parallel: 1
|
||||||
matrix:
|
matrix: ${{ fromJson(needs.resolve-build-targets.outputs.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
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -103,6 +208,8 @@ jobs:
|
|||||||
echo "tag_latest=latest"
|
echo "tag_latest=latest"
|
||||||
echo "tag_branch=${SAFE_REF}"
|
echo "tag_branch=${SAFE_REF}"
|
||||||
echo "tag_sha=${SHORT_SHA}"
|
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 "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}"
|
||||||
echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}"
|
echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}"
|
||||||
echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}"
|
echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}"
|
||||||
@@ -200,6 +307,8 @@ jobs:
|
|||||||
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
||||||
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
||||||
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
|
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 }}
|
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_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }}
|
||||||
ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }}
|
ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }}
|
||||||
@@ -222,8 +331,12 @@ jobs:
|
|||||||
--file "${DOCKERFILE}" \
|
--file "${DOCKERFILE}" \
|
||||||
"${BUILD_ARGS[@]}" \
|
"${BUILD_ARGS[@]}" \
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
--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_BRANCH}" \
|
||||||
--cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \
|
--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" \
|
--cache-to "type=inline" \
|
||||||
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
||||||
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
||||||
@@ -244,3 +357,57 @@ jobs:
|
|||||||
echo "- ${IMAGE_BASE}:${TAG_LATEST}"
|
echo "- ${IMAGE_BASE}:${TAG_LATEST}"
|
||||||
echo "- ${IMAGE_BASE}:${TAG_BRANCH}"
|
echo "- ${IMAGE_BASE}:${TAG_BRANCH}"
|
||||||
echo "- ${IMAGE_BASE}:${TAG_SHA}"
|
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
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -143,6 +143,7 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en
|
|||||||
- Secrets
|
- Secrets
|
||||||
- `REGISTRY_USERNAME`
|
- `REGISTRY_USERNAME`
|
||||||
- `REGISTRY_TOKEN`
|
- `REGISTRY_TOKEN`
|
||||||
|
- `INDEXNOW_KEY`(可选;如果要在主分支镜像发布后自动提交 IndexNow)
|
||||||
- Variables(可选)
|
- Variables(可选)
|
||||||
- `REGISTRY_HOST`(默认 `git.init.cool`)
|
- `REGISTRY_HOST`(默认 `git.init.cool`)
|
||||||
- `IMAGE_NAMESPACE`(默认仓库 owner)
|
- `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_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_FRONTEND_BASE_URL`(admin 镜像构建注入的前台跳转默认基址,默认 `http://localhost:4321`;运行时可被 `ADMIN_FRONTEND_BASE_URL` 覆盖)
|
||||||
- `ADMIN_VITE_BASENAME`(可选;如果 admin 要挂在 `/admin` 这类路径前缀下,构建时设置为 `/admin`)
|
- `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
|
### MCP Server
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ export interface AdminAnalyticsResponse {
|
|||||||
recent_events: AnalyticsRecentEvent[]
|
recent_events: AnalyticsRecentEvent[]
|
||||||
providers_last_7d: AnalyticsProviderBucket[]
|
providers_last_7d: AnalyticsProviderBucket[]
|
||||||
top_referrers: AnalyticsReferrerBucket[]
|
top_referrers: AnalyticsReferrerBucket[]
|
||||||
|
ai_referrers_last_7d: AnalyticsReferrerBucket[]
|
||||||
|
ai_discovery_page_views_last_7d: number
|
||||||
popular_posts: AnalyticsPopularPost[]
|
popular_posts: AnalyticsPopularPost[]
|
||||||
daily_activity: AnalyticsDailyBucket[]
|
daily_activity: AnalyticsDailyBucket[]
|
||||||
}
|
}
|
||||||
@@ -409,6 +411,7 @@ export interface AdminSiteSettingsResponse {
|
|||||||
media_r2_secret_access_key: string | null
|
media_r2_secret_access_key: string | null
|
||||||
seo_default_og_image: string | null
|
seo_default_og_image: string | null
|
||||||
seo_default_twitter_handle: string | null
|
seo_default_twitter_handle: string | null
|
||||||
|
seo_wechat_share_qr_enabled: boolean
|
||||||
notification_webhook_url: string | null
|
notification_webhook_url: string | null
|
||||||
notification_channel_type: 'webhook' | 'ntfy' | string
|
notification_channel_type: 'webhook' | 'ntfy' | string
|
||||||
notification_comment_enabled: boolean
|
notification_comment_enabled: boolean
|
||||||
@@ -482,6 +485,7 @@ export interface SiteSettingsPayload {
|
|||||||
mediaR2SecretAccessKey?: string | null
|
mediaR2SecretAccessKey?: string | null
|
||||||
seoDefaultOgImage?: string | null
|
seoDefaultOgImage?: string | null
|
||||||
seoDefaultTwitterHandle?: string | null
|
seoDefaultTwitterHandle?: string | null
|
||||||
|
seoWechatShareQrEnabled?: boolean
|
||||||
notificationWebhookUrl?: string | null
|
notificationWebhookUrl?: string | null
|
||||||
notificationChannelType?: 'webhook' | 'ntfy' | string | null
|
notificationChannelType?: 'webhook' | 'ntfy' | string | null
|
||||||
notificationCommentEnabled?: boolean
|
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 { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -80,6 +80,31 @@ function formatDuration(value: number | null) {
|
|||||||
return `${minutes} 分 ${restSeconds} 秒`
|
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() {
|
export function AnalyticsPage() {
|
||||||
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
|
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -197,6 +222,11 @@ export function AnalyticsPage() {
|
|||||||
icon: Clock3,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -241,6 +271,94 @@ export function AnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -491,7 +609,7 @@ export function AnalyticsPage() {
|
|||||||
key={item.referrer}
|
key={item.referrer}
|
||||||
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
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>
|
<Badge variant="outline">{item.count}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Rss,
|
Rss,
|
||||||
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Tags,
|
Tags,
|
||||||
Workflow,
|
Workflow,
|
||||||
@@ -37,7 +38,7 @@ import {
|
|||||||
formatReviewStatus,
|
formatReviewStatus,
|
||||||
formatReviewType,
|
formatReviewType,
|
||||||
} from '@/lib/admin-format'
|
} from '@/lib/admin-format'
|
||||||
import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
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() {
|
export function DashboardPage() {
|
||||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
||||||
|
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
@@ -78,13 +105,15 @@ export function DashboardPage() {
|
|||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [next, nextWorkerOverview] = await Promise.all([
|
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
||||||
adminApi.dashboard(),
|
adminApi.dashboard(),
|
||||||
adminApi.getWorkersOverview(),
|
adminApi.getWorkersOverview(),
|
||||||
|
adminApi.analytics(),
|
||||||
])
|
])
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setData(next)
|
setData(next)
|
||||||
setWorkerOverview(nextWorkerOverview)
|
setWorkerOverview(nextWorkerOverview)
|
||||||
|
setAnalytics(nextAnalytics)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
@@ -105,7 +134,7 @@ export function DashboardPage() {
|
|||||||
void loadDashboard(false)
|
void loadDashboard(false)
|
||||||
}, [loadDashboard])
|
}, [loadDashboard])
|
||||||
|
|
||||||
if (loading || !data || !workerOverview) {
|
if (loading || !data || !workerOverview || !analytics) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<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" />
|
<Skeleton key={index} className="h-44 rounded-3xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
|
||||||
<Skeleton className="h-[420px] rounded-3xl" />
|
<Skeleton className="h-[420px] rounded-3xl" />
|
||||||
|
<Skeleton className="h-[420px] rounded-3xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,6 +192,12 @@ export function DashboardPage() {
|
|||||||
icon: Workflow,
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -328,6 +366,73 @@ export function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
|||||||
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
|
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
|
||||||
seoDefaultOgImage: form.seo_default_og_image,
|
seoDefaultOgImage: form.seo_default_og_image,
|
||||||
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
||||||
|
seoWechatShareQrEnabled: form.seo_wechat_share_qr_enabled,
|
||||||
notificationWebhookUrl: form.notification_webhook_url,
|
notificationWebhookUrl: form.notification_webhook_url,
|
||||||
notificationChannelType: form.notification_channel_type,
|
notificationChannelType: form.notification_channel_type,
|
||||||
notificationCommentEnabled: form.notification_comment_enabled,
|
notificationCommentEnabled: form.notification_comment_enabled,
|
||||||
@@ -857,6 +858,24 @@ export function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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)]">
|
<div className="grid gap-4 lg:col-span-2 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
|
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
FROM rust:1.94-trixie AS chef
|
# syntax=docker/dockerfile:1.7
|
||||||
RUN cargo install cargo-chef --locked
|
|
||||||
|
FROM rust:1.94.1-trixie AS chef
|
||||||
|
RUN rustup component add rustfmt clippy \
|
||||||
|
&& cargo install cargo-chef --locked
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM chef AS planner
|
FROM chef AS planner
|
||||||
@@ -7,11 +10,20 @@ COPY . .
|
|||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
FROM chef AS builder
|
FROM chef AS builder
|
||||||
|
ENV CARGO_HOME=/usr/local/cargo \
|
||||||
|
CARGO_TARGET_DIR=/app/.cargo-target
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release --locked --recipe-path recipe.json
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
|
||||||
|
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
|
||||||
|
cargo chef cook --release --locked --recipe-path recipe.json
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release --locked --bin termi_api-cli
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
|
||||||
|
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
|
||||||
|
cargo build --release --locked --bin termi_api-cli \
|
||||||
|
&& install -Dm755 /app/.cargo-target/release/termi_api-cli /tmp/termi_api-cli
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -19,7 +31,7 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
|
COPY --from=builder /tmp/termi_api-cli /usr/local/bin/termi_api-cli
|
||||||
COPY --from=builder /app/config ./config
|
COPY --from=builder /app/config ./config
|
||||||
COPY --from=builder /app/assets ./assets
|
COPY --from=builder /app/assets ./assets
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
|
|||||||
mod m20260401_000034_add_source_markdown_to_posts;
|
mod m20260401_000034_add_source_markdown_to_posts;
|
||||||
mod m20260401_000035_add_human_verification_modes_to_site_settings;
|
mod m20260401_000035_add_human_verification_modes_to_site_settings;
|
||||||
mod m20260402_000036_create_worker_jobs;
|
mod m20260402_000036_create_worker_jobs;
|
||||||
|
mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -92,6 +93,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
|
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
|
||||||
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
|
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
|
||||||
Box::new(m20260402_000036_create_worker_jobs::Migration),
|
Box::new(m20260402_000036_create_worker_jobs::Migration),
|
||||||
|
Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "seo_wechat_share_qr_enabled")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("seo_wechat_share_qr_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null()
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if manager
|
||||||
|
.has_column("site_settings", "seo_wechat_share_qr_enabled")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.drop_column(Alias::new("seo_wechat_share_qr_enabled"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,6 +208,7 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub media_r2_secret_access_key: Option<String>,
|
pub media_r2_secret_access_key: Option<String>,
|
||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
|
pub seo_wechat_share_qr_enabled: bool,
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
pub notification_channel_type: String,
|
pub notification_channel_type: String,
|
||||||
pub notification_comment_enabled: bool,
|
pub notification_comment_enabled: bool,
|
||||||
@@ -827,6 +828,7 @@ fn build_settings_response(
|
|||||||
media_r2_secret_access_key: item.media_r2_secret_access_key,
|
media_r2_secret_access_key: item.media_r2_secret_access_key,
|
||||||
seo_default_og_image: item.seo_default_og_image,
|
seo_default_og_image: item.seo_default_og_image,
|
||||||
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
||||||
|
seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false),
|
||||||
notification_webhook_url: item.notification_webhook_url,
|
notification_webhook_url: item.notification_webhook_url,
|
||||||
notification_channel_type: item
|
notification_channel_type: item
|
||||||
.notification_channel_type
|
.notification_channel_type
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ pub struct SiteSettingsPayload {
|
|||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
#[serde(default, alias = "seoDefaultTwitterHandle")]
|
#[serde(default, alias = "seoDefaultTwitterHandle")]
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
|
#[serde(default, alias = "seoWechatShareQrEnabled")]
|
||||||
|
pub seo_wechat_share_qr_enabled: Option<bool>,
|
||||||
#[serde(default, alias = "notificationWebhookUrl")]
|
#[serde(default, alias = "notificationWebhookUrl")]
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
#[serde(default, alias = "notificationChannelType")]
|
#[serde(default, alias = "notificationChannelType")]
|
||||||
@@ -212,6 +214,7 @@ pub struct PublicSiteSettingsResponse {
|
|||||||
pub subscription_popup_delay_seconds: i32,
|
pub subscription_popup_delay_seconds: i32,
|
||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
|
pub seo_wechat_share_qr_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -693,6 +696,9 @@ impl SiteSettingsPayload {
|
|||||||
item.seo_default_twitter_handle =
|
item.seo_default_twitter_handle =
|
||||||
normalize_optional_string(Some(seo_default_twitter_handle));
|
normalize_optional_string(Some(seo_default_twitter_handle));
|
||||||
}
|
}
|
||||||
|
if let Some(seo_wechat_share_qr_enabled) = self.seo_wechat_share_qr_enabled {
|
||||||
|
item.seo_wechat_share_qr_enabled = Some(seo_wechat_share_qr_enabled);
|
||||||
|
}
|
||||||
if let Some(notification_webhook_url) = self.notification_webhook_url {
|
if let Some(notification_webhook_url) = self.notification_webhook_url {
|
||||||
item.notification_webhook_url =
|
item.notification_webhook_url =
|
||||||
normalize_optional_string(Some(notification_webhook_url));
|
normalize_optional_string(Some(notification_webhook_url));
|
||||||
@@ -848,6 +854,7 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
media_r2_secret_access_key: None,
|
media_r2_secret_access_key: None,
|
||||||
seo_default_og_image: None,
|
seo_default_og_image: None,
|
||||||
seo_default_twitter_handle: None,
|
seo_default_twitter_handle: None,
|
||||||
|
seo_wechat_share_qr_enabled: Some(false),
|
||||||
notification_webhook_url: None,
|
notification_webhook_url: None,
|
||||||
notification_channel_type: Some("webhook".to_string()),
|
notification_channel_type: Some("webhook".to_string()),
|
||||||
notification_comment_enabled: Some(false),
|
notification_comment_enabled: Some(false),
|
||||||
@@ -945,6 +952,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
|||||||
.unwrap_or_else(default_subscription_popup_delay_seconds),
|
.unwrap_or_else(default_subscription_popup_delay_seconds),
|
||||||
seo_default_og_image: model.seo_default_og_image,
|
seo_default_og_image: model.seo_default_og_image,
|
||||||
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
||||||
|
seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub struct Model {
|
|||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
|
pub seo_wechat_share_qr_enabled: Option<bool>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
pub notification_channel_type: Option<String>,
|
pub notification_channel_type: Option<String>,
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ pub struct AdminAnalyticsResponse {
|
|||||||
pub recent_events: Vec<AnalyticsRecentEvent>,
|
pub recent_events: Vec<AnalyticsRecentEvent>,
|
||||||
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
|
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
|
||||||
pub top_referrers: Vec<AnalyticsReferrerBucket>,
|
pub top_referrers: Vec<AnalyticsReferrerBucket>,
|
||||||
|
pub ai_referrers_last_7d: Vec<AnalyticsReferrerBucket>,
|
||||||
|
pub ai_discovery_page_views_last_7d: u64,
|
||||||
pub popular_posts: Vec<AnalyticsPopularPost>,
|
pub popular_posts: Vec<AnalyticsPopularPost>,
|
||||||
pub daily_activity: Vec<AnalyticsDailyBucket>,
|
pub daily_activity: Vec<AnalyticsDailyBucket>,
|
||||||
}
|
}
|
||||||
@@ -197,16 +199,112 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
|
|||||||
value.format("%Y-%m-%d %H:%M").to_string()
|
value.format("%Y-%m-%d %H:%M").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_referrer_source(value: Option<String>) -> String {
|
fn metadata_string(metadata: Option<&serde_json::Value>, key: &str) -> Option<String> {
|
||||||
|
metadata
|
||||||
|
.and_then(|value| value.get(key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.and_then(|value| trim_to_option(Some(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_path_query_value(path: Option<&str>, key: &str) -> Option<String> {
|
||||||
|
let path = path.and_then(|value| trim_to_option(Some(value.to_string())))?;
|
||||||
|
let synthetic_url = if path.starts_with("http://") || path.starts_with("https://") {
|
||||||
|
path
|
||||||
|
} else if path.starts_with('/') {
|
||||||
|
format!("https://local.test{path}")
|
||||||
|
} else {
|
||||||
|
format!("https://local.test/{path}")
|
||||||
|
};
|
||||||
|
|
||||||
|
reqwest::Url::parse(&synthetic_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| {
|
||||||
|
url.query_pairs()
|
||||||
|
.find(|(item_key, _)| item_key == key)
|
||||||
|
.map(|(_, value)| value.to_string())
|
||||||
|
})
|
||||||
|
.and_then(|value| trim_to_option(Some(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tracking_source_token(value: Option<String>) -> String {
|
||||||
let Some(value) = trim_to_option(value) else {
|
let Some(value) = trim_to_option(value) else {
|
||||||
return "direct".to_string();
|
return "direct".to_string();
|
||||||
};
|
};
|
||||||
|
|
||||||
reqwest::Url::parse(&value)
|
let normalized = reqwest::Url::parse(&value)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|url| url.host_str().map(ToString::to_string))
|
.and_then(|url| url.host_str().map(ToString::to_string))
|
||||||
.filter(|item| !item.trim().is_empty())
|
.filter(|item| !item.trim().is_empty())
|
||||||
.unwrap_or(value)
|
.unwrap_or(value)
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match normalized.as_str() {
|
||||||
|
"direct" => "direct".to_string(),
|
||||||
|
value if value.contains("chatgpt") || value.contains("openai") => {
|
||||||
|
"chatgpt-search".to_string()
|
||||||
|
}
|
||||||
|
value if value.contains("perplexity") => "perplexity".to_string(),
|
||||||
|
value if value.contains("copilot") || value.contains("bing") => {
|
||||||
|
"copilot-bing".to_string()
|
||||||
|
}
|
||||||
|
value if value.contains("gemini") => "gemini".to_string(),
|
||||||
|
value if value.contains("google") => "google".to_string(),
|
||||||
|
value if value.contains("claude") => "claude".to_string(),
|
||||||
|
value if value.contains("duckduckgo") => "duckduckgo".to_string(),
|
||||||
|
value if value.contains("kagi") => "kagi".to_string(),
|
||||||
|
_ => normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tracking_source(
|
||||||
|
path: Option<&str>,
|
||||||
|
referrer: Option<String>,
|
||||||
|
metadata: Option<&serde_json::Value>,
|
||||||
|
) -> String {
|
||||||
|
let preferred = metadata_string(metadata, "landingSource")
|
||||||
|
.or_else(|| metadata_string(metadata, "landing_source"))
|
||||||
|
.or_else(|| metadata_string(metadata, "utmSource"))
|
||||||
|
.or_else(|| metadata_string(metadata, "utm_source"))
|
||||||
|
.or_else(|| parse_path_query_value(path, "utm_source"))
|
||||||
|
.or_else(|| metadata_string(metadata, "referrerHost"))
|
||||||
|
.or_else(|| referrer);
|
||||||
|
|
||||||
|
normalize_tracking_source_token(preferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ai_discovery_source(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value,
|
||||||
|
"chatgpt-search" | "perplexity" | "copilot-bing" | "gemini" | "claude"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted_referrer_buckets(
|
||||||
|
breakdown: &HashMap<String, u64>,
|
||||||
|
predicate: impl Fn(&str) -> bool,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<AnalyticsReferrerBucket> {
|
||||||
|
let mut items = breakdown
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(referrer, count)| {
|
||||||
|
predicate(referrer)
|
||||||
|
.then(|| AnalyticsReferrerBucket {
|
||||||
|
referrer: referrer.clone(),
|
||||||
|
count: *count,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
items.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| left.referrer.cmp(&right.referrer))
|
||||||
|
});
|
||||||
|
items.truncate(limit);
|
||||||
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
||||||
@@ -550,7 +648,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
|||||||
page_views_last_24h += 1;
|
page_views_last_24h += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let referrer = normalize_referrer_source(event.referrer.clone());
|
let referrer =
|
||||||
|
normalize_tracking_source(Some(&event.path), event.referrer.clone(), event.metadata.as_ref());
|
||||||
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
|
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,17 +736,13 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
|||||||
});
|
});
|
||||||
providers_last_7d.truncate(6);
|
providers_last_7d.truncate(6);
|
||||||
|
|
||||||
let mut top_referrers = referrer_breakdown
|
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
|
||||||
.into_iter()
|
let ai_referrers_last_7d = sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
|
||||||
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
|
let ai_discovery_page_views_last_7d = referrer_breakdown
|
||||||
.collect::<Vec<_>>();
|
.iter()
|
||||||
top_referrers.sort_by(|left, right| {
|
.filter(|(referrer, _)| is_ai_discovery_source(referrer))
|
||||||
right
|
.map(|(_, count)| *count)
|
||||||
.count
|
.sum::<u64>();
|
||||||
.cmp(&left.count)
|
|
||||||
.then_with(|| left.referrer.cmp(&right.referrer))
|
|
||||||
});
|
|
||||||
top_referrers.truncate(8);
|
|
||||||
|
|
||||||
let mut popular_posts = post_breakdown
|
let mut popular_posts = post_breakdown
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -748,6 +843,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
|||||||
recent_events,
|
recent_events,
|
||||||
providers_last_7d,
|
providers_last_7d,
|
||||||
top_referrers,
|
top_referrers,
|
||||||
|
ai_referrers_last_7d,
|
||||||
|
ai_discovery_page_views_last_7d,
|
||||||
popular_posts,
|
popular_posts,
|
||||||
daily_activity,
|
daily_activity,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ PUBLIC_API_BASE_URL=
|
|||||||
# PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev
|
# PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev
|
||||||
PUBLIC_IMAGE_ALLOWED_HOSTS=
|
PUBLIC_IMAGE_ALLOWED_HOSTS=
|
||||||
|
|
||||||
|
# 如果你要启用 IndexNow 自动提交,请填写一个你自己的 key。
|
||||||
|
# frontend 会在 /indexnow-key.txt 暴露这个 key,配合 `pnpm indexnow:submit` 使用。
|
||||||
|
INDEXNOW_KEY=
|
||||||
|
|
||||||
# admin 浏览器请求 backend API 优先读取这个公开地址。
|
# admin 浏览器请求 backend API 优先读取这个公开地址。
|
||||||
# 如果留空,admin 会在生产环境按“当前访问主机 + :5150”回退。
|
# 如果留空,admin 会在生产环境按“当前访问主机 + :5150”回退。
|
||||||
# 如果你采用推荐方案(admin 域名同域转发 /api 到 backend),
|
# 如果你采用推荐方案(admin 域名同域转发 /api 到 backend),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ services:
|
|||||||
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: ${PUBLIC_COMMENT_TURNSTILE_SITE_KEY:-}
|
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: ${PUBLIC_COMMENT_TURNSTILE_SITE_KEY:-}
|
||||||
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-}
|
PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-}
|
||||||
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||||
# frontend 是 Astro SSR(Node) 服务,容器内部监听 4321
|
# frontend 是 Astro SSR(Node) 服务,容器内部监听 4321
|
||||||
# 生产建议由网关统一反代,仅对外开放 80/443
|
# 生产建议由网关统一反代,仅对外开放 80/443
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -58,3 +58,27 @@ admin 侧上传封面时也会额外做:
|
|||||||
- 上传前压缩
|
- 上传前压缩
|
||||||
- 16:9 封面规范化
|
- 16:9 封面规范化
|
||||||
- 优先转为 `AVIF / WebP`
|
- 优先转为 `AVIF / WebP`
|
||||||
|
|
||||||
|
## GEO / AI 搜索补充
|
||||||
|
|
||||||
|
前台现在额外提供:
|
||||||
|
|
||||||
|
- `/llms.txt`
|
||||||
|
- `/llms-full.txt`
|
||||||
|
- `/indexnow-key.txt`(仅在配置 `INDEXNOW_KEY` 时可用)
|
||||||
|
|
||||||
|
如果你想在发布后主动推送 IndexNow,可以配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
INDEXNOW_KEY=your-indexnow-key
|
||||||
|
SITE_URL=https://your-frontend.example.com
|
||||||
|
PUBLIC_API_BASE_URL=https://your-frontend.example.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
然后运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm indexnow:submit
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动收集首页、文章、分类、标签、评测等 canonical URL 并提交到 IndexNow。
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro",
|
||||||
|
"indexnow:submit": "node ./scripts/submit-indexnow.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/markdown-remark": "^7.0.1",
|
"@astrojs/markdown-remark": "^7.0.1",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"lucide-astro": "^0.556.0",
|
"lucide-astro": "^0.556.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svelte": "^5.55.0",
|
"svelte": "^5.55.0",
|
||||||
"tailwindcss": "^3.4.19"
|
"tailwindcss": "^3.4.19"
|
||||||
|
|||||||
148
frontend/pnpm-lock.yaml
generated
148
frontend/pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.8
|
specifier: ^8.5.8
|
||||||
version: 8.5.8
|
version: 8.5.8
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
@@ -838,6 +841,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001781:
|
caniuse-lite@1.0.30001781:
|
||||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||||
|
|
||||||
@@ -869,6 +876,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -942,6 +952,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||||
|
|
||||||
@@ -983,6 +997,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
@@ -1091,6 +1108,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
flattie@1.1.1:
|
flattie@1.1.1:
|
||||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1264,6 +1285,10 @@ packages:
|
|||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@@ -1499,10 +1524,18 @@ packages:
|
|||||||
oniguruma-to-es@4.3.5:
|
oniguruma-to-es@4.3.5:
|
||||||
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
p-limit@7.3.0:
|
p-limit@7.3.0:
|
||||||
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
|
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
p-queue@9.1.0:
|
p-queue@9.1.0:
|
||||||
resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==}
|
resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -1511,6 +1544,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
package-manager-detector@1.6.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
@@ -1523,6 +1560,10 @@ packages:
|
|||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
path-exists@4.0.0:
|
||||||
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
@@ -1548,6 +1589,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
postcss-import@15.1.0:
|
postcss-import@15.1.0:
|
||||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -1605,6 +1650,11 @@ packages:
|
|||||||
property-information@7.1.0:
|
property-information@7.1.0:
|
||||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -1681,6 +1731,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1729,6 +1782,9 @@ packages:
|
|||||||
server-destroy@1.0.1:
|
server-destroy@1.0.1:
|
||||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
@@ -2129,10 +2185,17 @@ packages:
|
|||||||
web-namespaces@2.0.1:
|
web-namespaces@2.0.1:
|
||||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which-pm-runs@1.1.0:
|
which-pm-runs@1.1.0:
|
||||||
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2140,6 +2203,9 @@ packages:
|
|||||||
xxhash-wasm@1.1.0:
|
xxhash-wasm@1.1.0:
|
||||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2158,6 +2224,10 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2166,6 +2236,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2971,6 +3045,8 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001781: {}
|
caniuse-lite@1.0.30001781: {}
|
||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
@@ -3003,6 +3079,12 @@ snapshots:
|
|||||||
|
|
||||||
ci-info@4.4.0: {}
|
ci-info@4.4.0: {}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@@ -3063,6 +3145,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.3.0:
|
decode-named-character-reference@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@@ -3091,6 +3175,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.4: {}
|
diff@8.0.4: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
@@ -3206,6 +3292,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
flattie@1.1.1: {}
|
flattie@1.1.1: {}
|
||||||
|
|
||||||
fontace@0.4.1:
|
fontace@0.4.1:
|
||||||
@@ -3412,6 +3503,10 @@ snapshots:
|
|||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
lru-cache@11.2.7: {}
|
lru-cache@11.2.7: {}
|
||||||
@@ -3818,10 +3913,18 @@ snapshots:
|
|||||||
regex: 6.1.0
|
regex: 6.1.0
|
||||||
regex-recursion: 6.0.2
|
regex-recursion: 6.0.2
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
p-limit@7.3.0:
|
p-limit@7.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 1.2.2
|
yocto-queue: 1.2.2
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
p-queue@9.1.0:
|
p-queue@9.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
eventemitter3: 5.0.4
|
eventemitter3: 5.0.4
|
||||||
@@ -3829,6 +3932,8 @@ snapshots:
|
|||||||
|
|
||||||
p-timeout@7.0.1: {}
|
p-timeout@7.0.1: {}
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
parse-latin@7.0.0:
|
parse-latin@7.0.0:
|
||||||
@@ -3846,6 +3951,8 @@ snapshots:
|
|||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
piccolore@0.1.3: {}
|
piccolore@0.1.3: {}
|
||||||
@@ -3860,6 +3967,8 @@ snapshots:
|
|||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.8):
|
postcss-import@15.1.0(postcss@8.5.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
@@ -3908,6 +4017,12 @@ snapshots:
|
|||||||
|
|
||||||
property-information@7.1.0: {}
|
property-information@7.1.0: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
radix3@1.1.2: {}
|
radix3@1.1.2: {}
|
||||||
@@ -4010,6 +4125,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
@@ -4102,6 +4219,8 @@ snapshots:
|
|||||||
|
|
||||||
server-destroy@1.0.1: {}
|
server-destroy@1.0.1: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
@@ -4509,8 +4628,16 @@ snapshots:
|
|||||||
|
|
||||||
web-namespaces@2.0.1: {}
|
web-namespaces@2.0.1: {}
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which-pm-runs@1.1.0: {}
|
which-pm-runs@1.1.0: {}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -4519,6 +4646,8 @@ snapshots:
|
|||||||
|
|
||||||
xxhash-wasm@1.1.0: {}
|
xxhash-wasm@1.1.0: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yaml-language-server@1.20.0:
|
yaml-language-server@1.20.0:
|
||||||
@@ -4539,10 +4668,29 @@ snapshots:
|
|||||||
|
|
||||||
yaml@2.8.3: {}
|
yaml@2.8.3: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
yargs-parser@22.0.0: {}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
134
frontend/scripts/submit-indexnow.mjs
Normal file
134
frontend/scripts/submit-indexnow.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const DEFAULT_API_PATH = '/api'
|
||||||
|
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow'
|
||||||
|
|
||||||
|
function normalizeBase(value) {
|
||||||
|
return String(value || '').trim().replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAbsolute(base, path) {
|
||||||
|
return `${normalizeBase(base)}${String(path).startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStaticRoutes(siteSettings) {
|
||||||
|
const routes = [
|
||||||
|
'/',
|
||||||
|
'/about',
|
||||||
|
'/articles',
|
||||||
|
'/categories',
|
||||||
|
'/tags',
|
||||||
|
'/timeline',
|
||||||
|
'/reviews',
|
||||||
|
'/friends',
|
||||||
|
'/rss.xml',
|
||||||
|
'/sitemap.xml',
|
||||||
|
'/llms.txt',
|
||||||
|
'/llms-full.txt',
|
||||||
|
'/indexnow-key.txt',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (siteSettings?.ai_enabled || siteSettings?.ai?.enabled) {
|
||||||
|
routes.push('/ask')
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const indexNowKey = String(process.env.INDEXNOW_KEY || '').trim()
|
||||||
|
if (!indexNowKey) {
|
||||||
|
throw new Error('Missing INDEXNOW_KEY environment variable.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredSiteUrl = normalizeBase(process.env.SITE_URL || process.env.PUBLIC_SITE_URL || '')
|
||||||
|
const configuredApiBaseUrl = normalizeBase(
|
||||||
|
process.env.INTERNAL_API_BASE_URL || process.env.PUBLIC_API_BASE_URL || '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const bootstrapApiBaseUrl = configuredApiBaseUrl || `${configuredSiteUrl}${DEFAULT_API_PATH}`
|
||||||
|
if (!bootstrapApiBaseUrl) {
|
||||||
|
throw new Error('Missing SITE_URL/PUBLIC_SITE_URL or API base URL for IndexNow submission.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteSettings = await fetchJson(`${bootstrapApiBaseUrl}/site_settings`).catch(() => null)
|
||||||
|
const siteUrl = normalizeBase(configuredSiteUrl || siteSettings?.site_url || '')
|
||||||
|
if (!siteUrl) {
|
||||||
|
throw new Error('Unable to determine canonical SITE_URL for IndexNow submission.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl = configuredApiBaseUrl || `${siteUrl}${DEFAULT_API_PATH}`
|
||||||
|
|
||||||
|
const [posts, categories, tags, reviews] = await Promise.all([
|
||||||
|
fetchJson(`${apiBaseUrl}/posts`).catch(() => []),
|
||||||
|
fetchJson(`${apiBaseUrl}/categories`).catch(() => []),
|
||||||
|
fetchJson(`${apiBaseUrl}/tags`).catch(() => []),
|
||||||
|
fetchJson(`${apiBaseUrl}/reviews`).catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
const urls = new Set(collectStaticRoutes(siteSettings).map((path) => ensureAbsolute(siteUrl, path)))
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
if (!post?.slug || post?.noindex === true) continue
|
||||||
|
urls.add(ensureAbsolute(siteUrl, `/articles/${encodeURIComponent(post.slug)}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const token = category?.slug || category?.name
|
||||||
|
if (!token) continue
|
||||||
|
urls.add(ensureAbsolute(siteUrl, `/categories/${encodeURIComponent(token)}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const token = tag?.slug || tag?.name
|
||||||
|
if (!token) continue
|
||||||
|
urls.add(ensureAbsolute(siteUrl, `/tags/${encodeURIComponent(token)}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (!review?.id) continue
|
||||||
|
urls.add(ensureAbsolute(siteUrl, `/reviews/${review.id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
host: new URL(siteUrl).host,
|
||||||
|
key: indexNowKey,
|
||||||
|
keyLocation: ensureAbsolute(siteUrl, '/indexnow-key.txt'),
|
||||||
|
urlList: [...urls],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(INDEXNOW_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text().catch(() => '')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`IndexNow submission failed: ${response.status} ${response.statusText}\n${responseText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`IndexNow submitted ${payload.urlList.length} URLs for ${siteUrl}`)
|
||||||
|
if (responseText) {
|
||||||
|
console.log(responseText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
86
frontend/src/components/seo/DiscoveryBrief.astro
Normal file
86
frontend/src/components/seo/DiscoveryBrief.astro
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
|
||||||
|
type FaqItem = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
badge?: string;
|
||||||
|
kicker?: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
highlights?: string[];
|
||||||
|
faqs?: FaqItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
badge = 'ai brief',
|
||||||
|
kicker = 'geo / summary',
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
highlights = [],
|
||||||
|
faqs = [],
|
||||||
|
} = Astro.props as Props;
|
||||||
|
const { locale } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.94),rgba(var(--primary-rgb),0.04))] p-5 sm:p-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--primary)]">
|
||||||
|
<i class="fas fa-brain text-[10px]"></i>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
<span class="terminal-kicker">
|
||||||
|
<i class="fas fa-sitemap"></i>
|
||||||
|
{kicker}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
|
||||||
|
<p class="mt-3 max-w-4xl text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
{isEnglish ? 'Key signals' : '关键信号'}
|
||||||
|
</div>
|
||||||
|
{highlights.length > 0 ? (
|
||||||
|
<ul class="mt-3 space-y-3">
|
||||||
|
{highlights.map((item, index) => (
|
||||||
|
<li class="flex items-start gap-3 text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
<span class="mt-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-xs font-semibold text-[var(--primary)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
{isEnglish ? 'FAQ' : '常见问答'}
|
||||||
|
</div>
|
||||||
|
{faqs.length > 0 ? (
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
{faqs.slice(0, 3).map((item) => (
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/65 bg-[var(--bg)]/60 px-4 py-3">
|
||||||
|
<p class="text-sm font-semibold leading-6 text-[var(--title-color)]">{item.question}</p>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
89
frontend/src/components/seo/PageViewTracker.astro
Normal file
89
frontend/src/components/seo/PageViewTracker.astro
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
pageType: string;
|
||||||
|
entityId?: string;
|
||||||
|
postSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = Astro.props;
|
||||||
|
const pageType = props.pageType;
|
||||||
|
const entityId = props.entityId ?? '';
|
||||||
|
const postSlug = props.postSlug ?? '';
|
||||||
|
---
|
||||||
|
|
||||||
|
<script is:inline define:vars={{ pageType, entityId, postSlug }}>
|
||||||
|
(() => {
|
||||||
|
const endpoint = '/api/analytics/content';
|
||||||
|
const storageKey = `termi:pageview:${pageType}:${entityId || postSlug || 'root'}`;
|
||||||
|
|
||||||
|
function ensureSessionId() {
|
||||||
|
try {
|
||||||
|
const existing = window.sessionStorage.getItem(storageKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const nextId = crypto.randomUUID();
|
||||||
|
window.sessionStorage.setItem(storageKey, nextId);
|
||||||
|
return nextId;
|
||||||
|
} catch {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferrerHost() {
|
||||||
|
try {
|
||||||
|
return document.referrer ? new URL(document.referrer).host : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSource(value) {
|
||||||
|
const source = String(value || '').trim().toLowerCase();
|
||||||
|
if (!source) return 'direct';
|
||||||
|
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
|
||||||
|
if (source.includes('perplexity')) return 'perplexity';
|
||||||
|
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
|
||||||
|
if (source.includes('gemini')) return 'gemini';
|
||||||
|
if (source.includes('google')) return 'google';
|
||||||
|
if (source.includes('claude')) return 'claude';
|
||||||
|
if (source.includes('duckduckgo')) return 'duckduckgo';
|
||||||
|
if (source.includes('kagi')) return 'kagi';
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetadata() {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const utmSource = currentUrl.searchParams.get('utm_source')?.trim() || '';
|
||||||
|
const utmMedium = currentUrl.searchParams.get('utm_medium')?.trim() || '';
|
||||||
|
const utmCampaign = currentUrl.searchParams.get('utm_campaign')?.trim() || '';
|
||||||
|
const utmTerm = currentUrl.searchParams.get('utm_term')?.trim() || '';
|
||||||
|
const utmContent = currentUrl.searchParams.get('utm_content')?.trim() || '';
|
||||||
|
const referrerHost = getReferrerHost();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageType,
|
||||||
|
entityId: entityId || undefined,
|
||||||
|
referrerHost: referrerHost || undefined,
|
||||||
|
utmSource: utmSource || undefined,
|
||||||
|
utmMedium: utmMedium || undefined,
|
||||||
|
utmCampaign: utmCampaign || undefined,
|
||||||
|
utmTerm: utmTerm || undefined,
|
||||||
|
utmContent: utmContent || undefined,
|
||||||
|
landingSource: normalizeSource(utmSource || referrerHost),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
keepalive: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
event_type: 'page_view',
|
||||||
|
path: `${window.location.pathname}${window.location.search}`,
|
||||||
|
post_slug: postSlug || undefined,
|
||||||
|
session_id: ensureSessionId(),
|
||||||
|
referrer: document.referrer || undefined,
|
||||||
|
metadata: buildMetadata(),
|
||||||
|
}),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
630
frontend/src/components/seo/SharePanel.astro
Normal file
630
frontend/src/components/seo/SharePanel.astro
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
---
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
|
||||||
|
type ShareStat = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shareTitle: string;
|
||||||
|
summary: string;
|
||||||
|
canonicalUrl: string;
|
||||||
|
badge?: string;
|
||||||
|
kicker?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
stats?: ShareStat[];
|
||||||
|
wechatShareQrEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
|
const {
|
||||||
|
shareTitle,
|
||||||
|
summary,
|
||||||
|
canonicalUrl,
|
||||||
|
badge = isEnglish ? 'distribution' : '快速分发',
|
||||||
|
kicker = 'geo / share',
|
||||||
|
title = isEnglish ? 'Share & AI discovery' : '分享与 AI 分发',
|
||||||
|
description = isEnglish
|
||||||
|
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
|
||||||
|
: '让规范链接持续通过社交渠道回流,方便用户传播,也方便 AI 搜索把信号聚合到同一个来源。',
|
||||||
|
stats = [],
|
||||||
|
wechatShareQrEnabled = false,
|
||||||
|
} = Astro.props as Props;
|
||||||
|
|
||||||
|
const copy = isEnglish
|
||||||
|
? {
|
||||||
|
summaryTitle: 'Share note',
|
||||||
|
canonical: 'Canonical',
|
||||||
|
copySummary: 'Copy note',
|
||||||
|
copySummarySuccess: 'Share note copied',
|
||||||
|
copySummaryFailed: 'Copy failed',
|
||||||
|
copyLink: 'Copy permalink',
|
||||||
|
copyLinkSuccess: 'Permalink copied',
|
||||||
|
copyLinkFailed: 'Permalink copy failed',
|
||||||
|
shareSummary: 'Share summary',
|
||||||
|
shareSuccess: 'Share panel opened',
|
||||||
|
shareFallback: 'Share text copied',
|
||||||
|
shareFailed: 'Share failed',
|
||||||
|
shareToX: 'Share to X',
|
||||||
|
shareToTelegram: 'Share to Telegram',
|
||||||
|
shareToWeChat: 'WeChat QR',
|
||||||
|
qrModalTitle: 'WeChat scan share',
|
||||||
|
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical URL on mobile.',
|
||||||
|
qrModalHint: 'Keep the canonical link as the single source of truth for social sharing and AI discovery.',
|
||||||
|
downloadQr: 'Download QR',
|
||||||
|
downloadQrStarted: 'QR download started',
|
||||||
|
qrOpened: 'WeChat QR ready',
|
||||||
|
toastSuccessTitle: 'Done',
|
||||||
|
toastErrorTitle: 'Action failed',
|
||||||
|
toastInfoTitle: 'Share ready',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
summaryTitle: '分享摘要',
|
||||||
|
canonical: '规范地址',
|
||||||
|
copySummary: '复制摘要',
|
||||||
|
copySummarySuccess: '分享摘要已复制',
|
||||||
|
copySummaryFailed: '复制失败',
|
||||||
|
copyLink: '复制固定链接',
|
||||||
|
copyLinkSuccess: '固定链接已复制',
|
||||||
|
copyLinkFailed: '固定链接复制失败',
|
||||||
|
shareSummary: '分享摘要',
|
||||||
|
shareSuccess: '已打开分享面板',
|
||||||
|
shareFallback: '分享文案已复制',
|
||||||
|
shareFailed: '分享失败',
|
||||||
|
shareToX: '分享到 X',
|
||||||
|
shareToTelegram: '分享到 Telegram',
|
||||||
|
shareToWeChat: '微信扫码',
|
||||||
|
qrModalTitle: '微信扫码分享',
|
||||||
|
qrModalDescription: '使用本地生成的二维码,在微信里扫一扫,就能直接打开当前页面的规范链接。',
|
||||||
|
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一个页面。',
|
||||||
|
downloadQr: '下载二维码',
|
||||||
|
downloadQrStarted: '二维码开始下载',
|
||||||
|
qrOpened: '微信二维码已打开',
|
||||||
|
toastSuccessTitle: '操作完成',
|
||||||
|
toastErrorTitle: '操作失败',
|
||||||
|
toastInfoTitle: '分享渠道已就绪',
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeSummary = summary.trim() || shareTitle;
|
||||||
|
const panelIdSeed = `${shareTitle}-${canonicalUrl}`
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
const panelId = `share-${panelIdSeed.slice(0, 48) || 'panel'}`;
|
||||||
|
const shareClipboardText = [shareTitle, safeSummary, `${copy.canonical}: ${canonicalUrl}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
const shareSummaryText = [shareTitle, safeSummary, canonicalUrl].filter(Boolean).join('\n\n');
|
||||||
|
const shareTeaser = [shareTitle, safeSummary].filter(Boolean).join(' — ').slice(0, 220);
|
||||||
|
const xShareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(shareTeaser)}&url=${encodeURIComponent(canonicalUrl)}`;
|
||||||
|
const telegramShareUrl = `https://t.me/share/url?url=${encodeURIComponent(canonicalUrl)}&text=${encodeURIComponent(shareTeaser)}`;
|
||||||
|
|
||||||
|
let wechatShareQrSvg = '';
|
||||||
|
let wechatShareQrPngDataUrl = '';
|
||||||
|
if (wechatShareQrEnabled) {
|
||||||
|
try {
|
||||||
|
wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
|
||||||
|
type: 'svg',
|
||||||
|
margin: 1,
|
||||||
|
width: 220,
|
||||||
|
color: {
|
||||||
|
dark: '#111827',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
|
||||||
|
margin: 1,
|
||||||
|
width: 360,
|
||||||
|
color: {
|
||||||
|
dark: '#111827',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Share panel QR generation error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.1),rgba(var(--secondary-rgb),0.04)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6"
|
||||||
|
data-share-panel-id={panelId}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
||||||
|
<i class="fas fa-satellite-dish text-[10px]"></i>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
<span class="terminal-kicker">
|
||||||
|
<i class="fas fa-share-nodes"></i>
|
||||||
|
{kicker}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
|
||||||
|
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.length > 0 ? (
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:min-w-[16rem]">
|
||||||
|
{stats.slice(0, 4).map((item) => (
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||||
|
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_16px_40px_rgba(15,23,42,0.06)]">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
|
||||||
|
<p class="mt-3 text-base leading-8 text-[var(--title-color)]">{safeSummary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-copy-summary
|
||||||
|
data-default-label={copy.copySummary}
|
||||||
|
data-success-label={copy.copySummarySuccess}
|
||||||
|
data-failed-label={copy.copySummaryFailed}
|
||||||
|
>
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
<span>{copy.copySummary}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-copy-link
|
||||||
|
data-default-label={copy.copyLink}
|
||||||
|
data-success-label={copy.copyLinkSuccess}
|
||||||
|
data-failed-label={copy.copyLinkFailed}
|
||||||
|
>
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
<span>{copy.copyLink}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
|
data-share-summary
|
||||||
|
data-default-label={copy.shareSummary}
|
||||||
|
data-success-label={copy.shareSuccess}
|
||||||
|
data-fallback-label={copy.shareFallback}
|
||||||
|
data-failed-label={copy.shareFailed}
|
||||||
|
>
|
||||||
|
<i class="fas fa-share-nodes"></i>
|
||||||
|
<span>{copy.shareSummary}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||||
|
{isEnglish ? 'Share channels' : '分享渠道'}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs leading-6 text-[var(--text-secondary)]">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={xShareUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-link
|
||||||
|
>
|
||||||
|
<i class="fab fa-twitter"></i>
|
||||||
|
<span>{copy.shareToX}</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={telegramShareUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-link
|
||||||
|
>
|
||||||
|
<i class="fab fa-telegram-plane"></i>
|
||||||
|
<span>{copy.shareToTelegram}</span>
|
||||||
|
</a>
|
||||||
|
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-wechat-open
|
||||||
|
>
|
||||||
|
<i class="fab fa-weixin"></i>
|
||||||
|
<span>{copy.shareToWeChat}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.canonical}</div>
|
||||||
|
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm"
|
||||||
|
data-share-wechat-modal
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="terminal-kicker">
|
||||||
|
<i class="fab fa-weixin"></i>
|
||||||
|
{copy.shareToWeChat}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{copy.qrModalTitle}</h3>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
{copy.qrModalDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||||
|
data-share-wechat-close
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<i class="fas fa-xmark text-base"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||||
|
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
||||||
|
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||||
|
{copy.canonical}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||||
|
{copy.summaryTitle}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
|
data-share-copy-link
|
||||||
|
data-default-label={copy.copyLink}
|
||||||
|
data-success-label={copy.copyLinkSuccess}
|
||||||
|
data-failed-label={copy.copyLinkFailed}
|
||||||
|
>
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
<span>{copy.copyLink}</span>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={wechatShareQrPngDataUrl}
|
||||||
|
download={`${panelId}-wechat-share-qr.png`}
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-qr-download
|
||||||
|
>
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span>{copy.downloadQr}</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-share-wechat-close
|
||||||
|
>
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
<span>{t('common.close')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed bottom-5 right-5 z-[70] w-[min(22rem,calc(100vw-1.5rem))] translate-y-4 opacity-0 transition-all duration-300"
|
||||||
|
data-share-toast
|
||||||
|
data-title-success={copy.toastSuccessTitle}
|
||||||
|
data-title-error={copy.toastErrorTitle}
|
||||||
|
data-title-info={copy.toastInfoTitle}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div class="rounded-2xl border border-emerald-500/25 bg-[var(--terminal-bg)]/96 p-4 shadow-[0_18px_50px_rgba(15,23,42,0.18)] backdrop-blur">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/12 text-emerald-400"
|
||||||
|
data-share-toast-icon
|
||||||
|
>
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-[var(--title-color)]" data-share-toast-title>
|
||||||
|
{copy.toastSuccessTitle}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]" data-share-toast-message></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script
|
||||||
|
is:inline
|
||||||
|
define:vars={{
|
||||||
|
panelId,
|
||||||
|
shareClipboardText,
|
||||||
|
shareSummaryText,
|
||||||
|
canonicalUrl,
|
||||||
|
shareTitle,
|
||||||
|
qrOpenedLabel: copy.qrOpened,
|
||||||
|
qrDownloadStartedLabel: copy.downloadQrStarted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(() => {
|
||||||
|
const root = document.querySelector(`[data-share-panel-id="${panelId}"]`);
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const copySummaryButton = root.querySelector('[data-share-copy-summary]');
|
||||||
|
const shareSummaryButton = root.querySelector('[data-share-summary]');
|
||||||
|
const copyLinkButtons = root.querySelectorAll('[data-share-copy-link]');
|
||||||
|
const shareLinks = root.querySelectorAll('[data-share-link]');
|
||||||
|
const wechatOpenButtons = root.querySelectorAll('[data-share-wechat-open]');
|
||||||
|
const wechatCloseButtons = root.querySelectorAll('[data-share-wechat-close]');
|
||||||
|
const wechatModal = root.querySelector('[data-share-wechat-modal]');
|
||||||
|
const qrDownloadButtons = root.querySelectorAll('[data-share-qr-download]');
|
||||||
|
const status = root.querySelector('[data-share-status]');
|
||||||
|
const toast = root.querySelector('[data-share-toast]');
|
||||||
|
const toastIcon = root.querySelector('[data-share-toast-icon]');
|
||||||
|
const toastTitle = root.querySelector('[data-share-toast-title]');
|
||||||
|
const toastMessage = root.querySelector('[data-share-toast-message]');
|
||||||
|
let toastTimer = 0;
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
if (!toast || !toastIcon || !toastTitle || !toastMessage) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
toast.getAttribute(`data-title-${type}`) ||
|
||||||
|
toast.getAttribute('data-title-success') ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
toastTitle.textContent = title;
|
||||||
|
toastMessage.textContent = message || '';
|
||||||
|
toast.classList.remove('opacity-0', 'translate-y-4');
|
||||||
|
|
||||||
|
toastIcon.className = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl';
|
||||||
|
const iconElement = toastIcon.querySelector('i');
|
||||||
|
if (iconElement) {
|
||||||
|
iconElement.className =
|
||||||
|
type === 'error'
|
||||||
|
? 'fas fa-triangle-exclamation'
|
||||||
|
: type === 'info'
|
||||||
|
? 'fas fa-share-nodes'
|
||||||
|
: 'fas fa-check';
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastCard = toast.firstElementChild;
|
||||||
|
toastCard?.classList.remove('border-emerald-500/25', 'border-rose-500/25', 'border-sky-500/25');
|
||||||
|
toastIcon.classList.remove(
|
||||||
|
'bg-emerald-500/12',
|
||||||
|
'text-emerald-400',
|
||||||
|
'bg-rose-500/12',
|
||||||
|
'text-rose-400',
|
||||||
|
'bg-sky-500/12',
|
||||||
|
'text-sky-400',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
toastCard?.classList.add('border-rose-500/25');
|
||||||
|
toastIcon.classList.add('bg-rose-500/12', 'text-rose-400');
|
||||||
|
} else if (type === 'info') {
|
||||||
|
toastCard?.classList.add('border-sky-500/25');
|
||||||
|
toastIcon.classList.add('bg-sky-500/12', 'text-sky-400');
|
||||||
|
} else {
|
||||||
|
toastCard?.classList.add('border-emerald-500/25');
|
||||||
|
toastIcon.classList.add('bg-emerald-500/12', 'text-emerald-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(toastTimer);
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonState(button, iconClass, label) {
|
||||||
|
if (!button) return;
|
||||||
|
button.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetButton(button, fallbackIconClass) {
|
||||||
|
if (!button) return;
|
||||||
|
const defaultLabel = button.getAttribute('data-default-label') || '';
|
||||||
|
setButtonState(button, fallbackIconClass, defaultLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeClipboard(value) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopySummary() {
|
||||||
|
const successLabel = copySummaryButton?.getAttribute('data-success-label') || '';
|
||||||
|
const failedLabel = copySummaryButton?.getAttribute('data-failed-label') || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeClipboard(shareClipboardText);
|
||||||
|
setButtonState(copySummaryButton, 'fas fa-check', successLabel);
|
||||||
|
setStatus(successLabel);
|
||||||
|
showToast(successLabel, 'success');
|
||||||
|
} catch {
|
||||||
|
setButtonState(copySummaryButton, 'fas fa-triangle-exclamation', failedLabel);
|
||||||
|
setStatus(failedLabel);
|
||||||
|
showToast(failedLabel, 'error');
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
resetButton(copySummaryButton, 'fas fa-copy');
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLink(button) {
|
||||||
|
const successLabel = button?.getAttribute('data-success-label') || '';
|
||||||
|
const failedLabel = button?.getAttribute('data-failed-label') || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeClipboard(canonicalUrl);
|
||||||
|
setButtonState(button, 'fas fa-check', successLabel);
|
||||||
|
setStatus(successLabel);
|
||||||
|
showToast(successLabel, 'success');
|
||||||
|
} catch {
|
||||||
|
setButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
|
||||||
|
setStatus(failedLabel);
|
||||||
|
showToast(failedLabel, 'error');
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
resetButton(button, 'fas fa-link');
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShareSummary() {
|
||||||
|
const successLabel = shareSummaryButton?.getAttribute('data-success-label') || '';
|
||||||
|
const fallbackLabel = shareSummaryButton?.getAttribute('data-fallback-label') || '';
|
||||||
|
const failedLabel = shareSummaryButton?.getAttribute('data-failed-label') || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: shareTitle,
|
||||||
|
text: shareSummaryText,
|
||||||
|
url: canonicalUrl,
|
||||||
|
});
|
||||||
|
setButtonState(shareSummaryButton, 'fas fa-check', successLabel);
|
||||||
|
setStatus(successLabel);
|
||||||
|
showToast(successLabel, 'success');
|
||||||
|
} else {
|
||||||
|
await writeClipboard(shareSummaryText);
|
||||||
|
setButtonState(shareSummaryButton, 'fas fa-copy', fallbackLabel);
|
||||||
|
setStatus(fallbackLabel);
|
||||||
|
showToast(fallbackLabel, 'info');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
resetButton(shareSummaryButton, 'fas fa-share-nodes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonState(shareSummaryButton, 'fas fa-triangle-exclamation', failedLabel);
|
||||||
|
setStatus(failedLabel);
|
||||||
|
showToast(failedLabel, 'error');
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
resetButton(shareSummaryButton, 'fas fa-share-nodes');
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWechatModal() {
|
||||||
|
if (!wechatModal) return;
|
||||||
|
wechatModal.classList.remove('hidden');
|
||||||
|
wechatModal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
setStatus(qrOpenedLabel);
|
||||||
|
showToast(qrOpenedLabel, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWechatModal() {
|
||||||
|
if (!wechatModal) return;
|
||||||
|
wechatModal.classList.add('hidden');
|
||||||
|
wechatModal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
copySummaryButton?.addEventListener('click', async () => {
|
||||||
|
await handleCopySummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
shareSummaryButton?.addEventListener('click', async () => {
|
||||||
|
await handleShareSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
copyLinkButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
await handleCopyLink(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
shareLinks.forEach((link) => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
const label = link.textContent?.trim() || '';
|
||||||
|
if (!label) return;
|
||||||
|
setStatus(label);
|
||||||
|
showToast(label, 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wechatOpenButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
openWechatModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wechatCloseButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
closeWechatModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
qrDownloadButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setStatus(qrDownloadStartedLabel);
|
||||||
|
showToast(qrDownloadStartedLabel, 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wechatModal?.addEventListener('click', (event) => {
|
||||||
|
if (event.target === wechatModal) {
|
||||||
|
closeWechatModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && wechatModal && !wechatModal.classList.contains('hidden')) {
|
||||||
|
closeWechatModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -65,6 +65,19 @@ const defaultJsonLdObjects = [
|
|||||||
'query-input': 'required name=search_term_string',
|
'query-input': 'required name=search_term_string',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
alternateName: siteSettings.siteShortName,
|
||||||
|
url: siteUrl,
|
||||||
|
description,
|
||||||
|
logo: siteSettings.ownerAvatarUrl || ogImage,
|
||||||
|
sameAs: [
|
||||||
|
siteSettings.social.github,
|
||||||
|
siteSettings.social.twitter,
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Person',
|
'@type': 'Person',
|
||||||
@@ -97,8 +110,10 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<meta name="author" content={siteSettings.ownerName} />
|
||||||
<meta name="robots" content={props.noindex ? 'noindex, nofollow' : 'index, follow'} />
|
<meta name="robots" content={props.noindex ? 'noindex, nofollow' : 'index, follow'} />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
<meta property="og:locale" content={locale} />
|
||||||
<meta property="og:site_name" content={siteSettings.siteName} />
|
<meta property="og:site_name" content={siteSettings.siteName} />
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
@@ -115,9 +130,28 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
|||||||
)}
|
)}
|
||||||
{ogImage && <meta property="og:image" content={ogImage} />}
|
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
type="application/rss+xml"
|
||||||
|
title={`${siteSettings.siteName} RSS`}
|
||||||
|
href={`${siteUrl}/rss.xml`}
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
type="text/plain"
|
||||||
|
title={`${siteSettings.siteName} llms.txt`}
|
||||||
|
href={`${siteUrl}/llms.txt`}
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
type="text/plain"
|
||||||
|
title={`${siteSettings.siteName} llms-full.txt`}
|
||||||
|
href={`${siteUrl}/llms-full.txt`}
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
|
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
|
||||||
|
<slot name="head" />
|
||||||
|
|
||||||
<style is:inline>
|
<style is:inline>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export interface ApiSiteSettings {
|
|||||||
subscription_popup_delay_seconds: number | null;
|
subscription_popup_delay_seconds: number | null;
|
||||||
seo_default_og_image: string | null;
|
seo_default_og_image: string | null;
|
||||||
seo_default_twitter_handle: string | null;
|
seo_default_twitter_handle: string | null;
|
||||||
|
seo_wechat_share_qr_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentAnalyticsInput {
|
export interface ContentAnalyticsInput {
|
||||||
@@ -491,6 +492,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
|||||||
seo: {
|
seo: {
|
||||||
defaultOgImage: undefined,
|
defaultOgImage: undefined,
|
||||||
defaultTwitterHandle: undefined,
|
defaultTwitterHandle: undefined,
|
||||||
|
wechatShareQrEnabled: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -509,6 +511,8 @@ const normalizePost = (post: ApiPost): UiPost => ({
|
|||||||
description: post.description,
|
description: post.description,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
date: formatPostDate(post.created_at),
|
date: formatPostDate(post.created_at),
|
||||||
|
createdAt: post.created_at,
|
||||||
|
updatedAt: post.updated_at,
|
||||||
readTime: estimateReadTime(post.content || post.description),
|
readTime: estimateReadTime(post.content || post.description),
|
||||||
type: post.post_type,
|
type: post.post_type,
|
||||||
tags: post.tags ?? [],
|
tags: post.tags ?? [],
|
||||||
@@ -662,6 +666,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
|||||||
seo: {
|
seo: {
|
||||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||||
|
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
276
frontend/src/lib/seo.ts
Normal file
276
frontend/src/lib/seo.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import type { Post, SiteSettings } from './types';
|
||||||
|
import { buildCategoryUrl, buildTagUrl } from './utils';
|
||||||
|
|
||||||
|
export interface ArticleFaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryFaqOptions {
|
||||||
|
locale: string;
|
||||||
|
pageTitle: string;
|
||||||
|
summary: string;
|
||||||
|
primaryUrl: string;
|
||||||
|
primaryLabel: string;
|
||||||
|
relatedLinks?: Array<{
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
signals?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMarkdown(value: string): string {
|
||||||
|
return normalizeWhitespace(
|
||||||
|
value
|
||||||
|
.replace(/^---[\s\S]*?---/, ' ')
|
||||||
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||||
|
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||||
|
.replace(/`{1,3}[^`]*`{1,3}/g, ' ')
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/[>*_~|]/g, ' ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, maxLength: number): string {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmpty(values: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return values.filter((value) => {
|
||||||
|
const normalized = normalizeWhitespace(value).toLowerCase();
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(normalized);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitMarkdownParagraphs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((item) => stripMarkdown(item))
|
||||||
|
.map((item) => item.replace(/^#\s+/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSentences(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(/(?<=[。!?!?;;])\s*|(?<=\.)\s+/)
|
||||||
|
.map((item) => normalizeWhitespace(item))
|
||||||
|
.filter((item) => item.length >= 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArticleSynopsis(
|
||||||
|
post: Pick<Post, 'title' | 'description' | 'content'>,
|
||||||
|
maxLength = 220,
|
||||||
|
): string {
|
||||||
|
const contentParagraphs = splitMarkdownParagraphs(post.content || '').filter(
|
||||||
|
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
|
||||||
|
);
|
||||||
|
const parts = uniqueNonEmpty([post.description, ...contentParagraphs].filter(Boolean));
|
||||||
|
|
||||||
|
return truncate(parts.join(' '), maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArticleHighlights(
|
||||||
|
post: Pick<Post, 'title' | 'description' | 'content'>,
|
||||||
|
limit = 3,
|
||||||
|
): string[] {
|
||||||
|
const paragraphs = splitMarkdownParagraphs(post.content || '').filter(
|
||||||
|
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
|
||||||
|
);
|
||||||
|
const sentences = uniqueNonEmpty(
|
||||||
|
[post.description, ...paragraphs]
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap((item) => splitSentences(item || '')),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sentences.slice(0, limit).map((item) => truncate(item, 88));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePostUpdatedAt(post: Pick<Post, 'updatedAt' | 'publishAt' | 'createdAt' | 'date'>): string {
|
||||||
|
return post.updatedAt || post.publishAt || post.createdAt || post.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArticleFaqs(
|
||||||
|
post: Pick<Post, 'title' | 'category' | 'tags'>,
|
||||||
|
options: {
|
||||||
|
locale: string;
|
||||||
|
summary: string;
|
||||||
|
readTimeMinutes: number;
|
||||||
|
},
|
||||||
|
): ArticleFaqItem[] {
|
||||||
|
const isEnglish = options.locale.startsWith('en');
|
||||||
|
const tags = post.tags.slice(0, 5);
|
||||||
|
const keywordList = tags.length ? tags.join(isEnglish ? ', ' : '、') : post.category;
|
||||||
|
const categoryUrl = buildCategoryUrl(post.category);
|
||||||
|
const tagUrls = tags.slice(0, 2).map((tag) => buildTagUrl(tag));
|
||||||
|
|
||||||
|
const items = isEnglish
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
question: `What is "${post.title}" mainly about?`,
|
||||||
|
answer: options.summary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What keywords or topics appear in this page?',
|
||||||
|
answer: `This page belongs to ${post.category} and highlights ${keywordList}. Estimated reading time is about ${Math.max(
|
||||||
|
options.readTimeMinutes,
|
||||||
|
1,
|
||||||
|
)} minute(s).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Where should I continue if I want related content?',
|
||||||
|
answer: `Start with the category page ${categoryUrl} and then continue with related tags ${tagUrls.join(
|
||||||
|
', ',
|
||||||
|
) || buildTagUrl('')}.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
question: `《${post.title}》主要讲什么?`,
|
||||||
|
answer: options.summary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: '这页内容涉及哪些关键词?',
|
||||||
|
answer: `这篇内容归档在「${post.category}」,重点关键词包括 ${keywordList},预计阅读时间约 ${Math.max(
|
||||||
|
options.readTimeMinutes,
|
||||||
|
1,
|
||||||
|
)} 分钟。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: '如果想继续阅读相关内容,应该从哪里开始?',
|
||||||
|
answer: `建议先看分类页 ${categoryUrl},再继续浏览相关标签 ${tagUrls.join('、') || buildTagUrl('')}。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
question: truncate(item.question, 120),
|
||||||
|
answer: truncate(item.answer, 320),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiscoveryHighlights(values: string[], limit = 3, maxLength = 96): string[] {
|
||||||
|
return uniqueNonEmpty(values.map((item) => normalizeWhitespace(item)).filter(Boolean))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((item) => truncate(item, maxLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageFaqs(options: DiscoveryFaqOptions): ArticleFaqItem[] {
|
||||||
|
const isEnglish = options.locale.startsWith('en');
|
||||||
|
const relatedLinks = (options.relatedLinks || []).slice(0, 3);
|
||||||
|
const relatedText = relatedLinks
|
||||||
|
.map((item) => `${item.label}: ${item.url}`)
|
||||||
|
.join(isEnglish ? '; ' : ';');
|
||||||
|
const signalText = uniqueNonEmpty(options.signals || []).join(isEnglish ? ', ' : '、');
|
||||||
|
|
||||||
|
const items = isEnglish
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
question: `What is the main purpose of "${options.pageTitle}"?`,
|
||||||
|
answer: options.summary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Which URL should be cited as the canonical source?',
|
||||||
|
answer: `Use ${options.primaryLabel} as the canonical page: ${options.primaryUrl}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Where should I continue for related information?',
|
||||||
|
answer: relatedText || signalText
|
||||||
|
? `Continue with ${relatedText || signalText}.`
|
||||||
|
: `Continue from the canonical page ${options.primaryUrl}.`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
question: `「${options.pageTitle}」这一页的核心作用是什么?`,
|
||||||
|
answer: options.summary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: '这一页应该引用哪个规范地址?',
|
||||||
|
answer: `优先引用 ${options.primaryLabel} 的规范地址:${options.primaryUrl}。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: '如果要继续浏览相关内容,应该看哪里?',
|
||||||
|
answer: relatedText || signalText
|
||||||
|
? `建议继续查看:${relatedText || signalText}。`
|
||||||
|
: `建议从这个规范页继续展开:${options.primaryUrl}。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
question: truncate(item.question, 120),
|
||||||
|
answer: truncate(item.answer, 320),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFaqJsonLd(faqs: ArticleFaqItem[]) {
|
||||||
|
if (!faqs.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqs.map((item) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.question,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: item.answer,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPostItemList(posts: Post[], siteUrl: string) {
|
||||||
|
return posts.map((post, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
url: new URL(`/articles/${post.slug}`, siteUrl).toString(),
|
||||||
|
name: post.title,
|
||||||
|
description: post.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageTrackerLabel(referrer: string | null | undefined): string {
|
||||||
|
const source = normalizeWhitespace(referrer || '').toLowerCase();
|
||||||
|
if (!source) {
|
||||||
|
return 'direct';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
|
||||||
|
if (source.includes('perplexity')) return 'perplexity';
|
||||||
|
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
|
||||||
|
if (source.includes('gemini')) return 'gemini';
|
||||||
|
if (source.includes('google')) return 'google';
|
||||||
|
if (source.includes('claude')) return 'claude';
|
||||||
|
if (source.includes('duckduckgo')) return 'duckduckgo';
|
||||||
|
if (source.includes('kagi')) return 'kagi';
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSiteTopicSummary(siteSettings: SiteSettings): string[] {
|
||||||
|
return uniqueNonEmpty([
|
||||||
|
siteSettings.siteDescription,
|
||||||
|
siteSettings.heroSubtitle,
|
||||||
|
siteSettings.ownerBio,
|
||||||
|
...siteSettings.techStack.slice(0, 6).map((item) => `${siteSettings.siteName} covers ${item}`),
|
||||||
|
]).slice(0, 4);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export interface Post {
|
|||||||
description: string;
|
description: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
readTime: string;
|
readTime: string;
|
||||||
type: 'article' | 'tweet';
|
type: 'article' | 'tweet';
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -105,6 +107,7 @@ export interface SiteSettings {
|
|||||||
seo: {
|
seo: {
|
||||||
defaultOgImage?: string;
|
defaultOgImage?: string;
|
||||||
defaultTwitterHandle?: string;
|
defaultTwitterHandle?: string;
|
||||||
|
wechatShareQrEnabled: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import StatsList from '../../components/StatsList.astro';
|
import StatsList from '../../components/StatsList.astro';
|
||||||
import TechStackList from '../../components/TechStackList.astro';
|
import TechStackList from '../../components/TechStackList.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let systemStats = [];
|
let systemStats = [];
|
||||||
let techStack = [];
|
let techStack = [];
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [settings, posts, tags, friendLinks] = await Promise.all([
|
const [settings, posts, tags, friendLinks] = await Promise.all([
|
||||||
@@ -42,13 +47,95 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const aboutCanonicalUrl = new URL('/about', siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'profile source',
|
||||||
|
title: 'Share the profile page',
|
||||||
|
description:
|
||||||
|
'Use this page as the canonical identity and capability profile so social sharing and AI search can cite one stable source.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '身份主页',
|
||||||
|
title: '分享这张身份名片页',
|
||||||
|
description: '把这页当成统一的身份与能力来源分发出去,方便社交回流,也方便 AI 搜索引用到同一个规范地址。',
|
||||||
|
};
|
||||||
|
const aboutHighlights = buildDiscoveryHighlights([
|
||||||
|
siteSettings.ownerTitle,
|
||||||
|
siteSettings.ownerBio,
|
||||||
|
siteSettings.location || '',
|
||||||
|
siteSettings.techStack.slice(0, 4).join(' / '),
|
||||||
|
]);
|
||||||
|
const aboutFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: t('about.pageTitle'),
|
||||||
|
summary: siteSettings.ownerBio || siteSettings.siteDescription,
|
||||||
|
primaryLabel: t('about.pageTitle'),
|
||||||
|
primaryUrl: aboutCanonicalUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
|
||||||
|
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
|
||||||
|
{ label: t('nav.ask'), url: `${siteBaseUrl}/ask` },
|
||||||
|
],
|
||||||
|
signals: aboutHighlights,
|
||||||
|
});
|
||||||
|
const aboutFaqJsonLd = buildFaqJsonLd(aboutFaqs);
|
||||||
|
const aboutJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'AboutPage',
|
||||||
|
name: `${siteSettings.ownerName} / ${siteSettings.siteName}`,
|
||||||
|
description: siteSettings.siteDescription,
|
||||||
|
url: aboutCanonicalUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ProfilePage',
|
||||||
|
name: siteSettings.ownerName,
|
||||||
|
url: aboutCanonicalUrl,
|
||||||
|
mainEntity: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: siteSettings.ownerName,
|
||||||
|
jobTitle: siteSettings.ownerTitle,
|
||||||
|
description: siteSettings.ownerBio,
|
||||||
|
image: siteSettings.ownerAvatarUrl || undefined,
|
||||||
|
sameAs: [
|
||||||
|
siteSettings.social.github,
|
||||||
|
siteSettings.social.twitter,
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
item: siteBaseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: t('about.pageTitle'),
|
||||||
|
item: aboutCanonicalUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
aboutFaqJsonLd,
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
|
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
|
||||||
description={siteSettings.siteDescription}
|
description={siteSettings.siteDescription}
|
||||||
siteSettings={siteSettings}
|
siteSettings={siteSettings}
|
||||||
|
jsonLd={aboutJsonLd.filter(Boolean)}
|
||||||
>
|
>
|
||||||
|
<PageViewTracker pageType="about" entityId="about" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/about" class="w-full">
|
<TerminalWindow title="~/about" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -77,6 +164,31 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${siteSettings.ownerName} / ${t('about.pageTitle')}`}
|
||||||
|
summary={siteSettings.ownerBio || siteSettings.siteDescription}
|
||||||
|
canonicalUrl={aboutCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / profile"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={systemStats.slice(0, 4)}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'profile brief' : '身份摘要'}
|
||||||
|
kicker="geo / profile"
|
||||||
|
title={isEnglish ? 'AI-readable profile brief' : '给 AI 看的身份摘要'}
|
||||||
|
summary={siteSettings.ownerBio || siteSettings.siteDescription}
|
||||||
|
highlights={aboutHighlights}
|
||||||
|
faqs={aboutFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import PostCard from '../../components/PostCard.astro';
|
import PostCard from '../../components/PostCard.astro';
|
||||||
import { api } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../../lib/seo';
|
||||||
import type { Category, Post, Tag } from '../../lib/types';
|
import type { Category, Post, Tag } from '../../lib/types';
|
||||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
|
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
|
||||||
|
|
||||||
@@ -18,11 +22,13 @@ const selectedTag = url.searchParams.get('tag') || '';
|
|||||||
const selectedCategory = url.searchParams.get('category') || '';
|
const selectedCategory = url.searchParams.get('category') || '';
|
||||||
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
||||||
const postsPerPage = 10;
|
const postsPerPage = 10;
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
let paginatedPosts: Post[] = [];
|
let paginatedPosts: Post[] = [];
|
||||||
let allTags: Tag[] = [];
|
let allTags: Tag[] = [];
|
||||||
let allCategories: Category[] = [];
|
let allCategories: Category[] = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let totalPosts = 0;
|
let totalPosts = 0;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||||
@@ -63,6 +69,12 @@ try {
|
|||||||
seenTagIds.add(key);
|
seenTagIds.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
siteSettings = await api.getSiteSettings();
|
||||||
|
} catch (settingsError) {
|
||||||
|
console.error('Failed to fetch site settings for articles index:', settingsError);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
}
|
}
|
||||||
@@ -91,6 +103,64 @@ const tagPromptCommand = selectedTag
|
|||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
||||||
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const absoluteCanonicalUrl = new URL('/articles', siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'content archive',
|
||||||
|
title: 'Share the article archive',
|
||||||
|
description:
|
||||||
|
'Use the articles index as the canonical discovery shelf for AI retrieval and readers, then branch into type, category, and tag filters from one source.',
|
||||||
|
posts: 'Posts',
|
||||||
|
categories: 'Categories',
|
||||||
|
tags: 'Tags',
|
||||||
|
page: 'Page',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '内容归档',
|
||||||
|
title: '分享文章总归档页',
|
||||||
|
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。',
|
||||||
|
posts: '文章数',
|
||||||
|
categories: '分类数',
|
||||||
|
tags: '标签数',
|
||||||
|
page: '页码',
|
||||||
|
};
|
||||||
|
const articleIndexHighlights = buildDiscoveryHighlights([
|
||||||
|
t('articlesPage.description'),
|
||||||
|
`${sharePanelCopy.posts}: ${totalPosts}`,
|
||||||
|
`${sharePanelCopy.categories}: ${allCategories.length}`,
|
||||||
|
`${sharePanelCopy.tags}: ${allTags.length}`,
|
||||||
|
selectedType !== 'all' ? `type=${selectedType}` : '',
|
||||||
|
]);
|
||||||
|
const articleIndexFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: t('articlesPage.title'),
|
||||||
|
summary: t('articlesPage.description'),
|
||||||
|
primaryLabel: t('articlesPage.title'),
|
||||||
|
primaryUrl: absoluteCanonicalUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
|
||||||
|
{ label: t('nav.tags'), url: `${siteBaseUrl}/tags` },
|
||||||
|
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
|
||||||
|
],
|
||||||
|
signals: articleIndexHighlights,
|
||||||
|
});
|
||||||
|
const articleIndexFaqJsonLd = buildFaqJsonLd(articleIndexFaqs);
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('articlesPage.title'),
|
||||||
|
description: t('articlesPage.description'),
|
||||||
|
url: new URL('/articles', siteBaseUrl).toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('articlesPage.title')} page ${currentPage}`,
|
||||||
|
itemListElement: buildPostItemList(paginatedPosts, siteBaseUrl),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const buildArticlesUrl = ({
|
const buildArticlesUrl = ({
|
||||||
type = selectedType,
|
type = selectedType,
|
||||||
@@ -120,9 +190,12 @@ const buildArticlesUrl = ({
|
|||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={`${t('articlesPage.title')} - Termi`}
|
title={`${t('articlesPage.title')} - Termi`}
|
||||||
|
siteSettings={siteSettings}
|
||||||
canonical={canonicalUrl}
|
canonical={canonicalUrl}
|
||||||
noindex={hasActiveFilters}
|
noindex={hasActiveFilters}
|
||||||
|
jsonLd={[...jsonLd, articleIndexFaqJsonLd].filter(Boolean)}
|
||||||
>
|
>
|
||||||
|
<PageViewTracker pageType="articles" entityId={`articles-page-${currentPage}`} />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/articles/index" class="w-full">
|
<TerminalWindow title="~/articles/index" class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
@@ -163,6 +236,34 @@ const buildArticlesUrl = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('articlesPage.title')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('articlesPage.description')}
|
||||||
|
canonicalUrl={absoluteCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / archive"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.posts, value: String(totalPosts) },
|
||||||
|
{ label: sharePanelCopy.categories, value: String(allCategories.length) },
|
||||||
|
{ label: sharePanelCopy.tags, value: String(allTags.length) },
|
||||||
|
{ label: sharePanelCopy.page, value: `${currentPage}/${Math.max(totalPages, 1)}` },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'archive brief' : '归档摘要'}
|
||||||
|
kicker="geo / archive"
|
||||||
|
title={isEnglish ? 'AI-readable archive brief' : '给 AI 看的归档摘要'}
|
||||||
|
summary={t('articlesPage.description')}
|
||||||
|
highlights={articleIndexHighlights}
|
||||||
|
faqs={articleIndexFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -28,13 +33,80 @@ const sampleQuestions = [
|
|||||||
? 'What is the site owner\'s tech stack and personal profile?'
|
? 'What is the site owner\'s tech stack and personal profile?'
|
||||||
: '站长的技术栈和个人介绍是什么?'
|
: '站长的技术栈和个人介绍是什么?'
|
||||||
];
|
];
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const askCanonicalUrl = new URL('/ask', siteBaseUrl).toString();
|
||||||
|
const askJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: t('ask.title'),
|
||||||
|
description: t('ask.pageDescription', { siteName: siteSettings.siteName }),
|
||||||
|
url: askCanonicalUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
item: siteBaseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: t('ask.title'),
|
||||||
|
item: askCanonicalUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'ai search',
|
||||||
|
title: 'Share the AI ask page',
|
||||||
|
description:
|
||||||
|
'Share the site’s AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
|
||||||
|
examples: 'Prompts',
|
||||||
|
ai: 'AI',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: 'AI 检索',
|
||||||
|
title: '分享站内 AI 问答页',
|
||||||
|
description: '把这个 AI 问答入口作为基于问题的规范发现页分发出去,方便用户与 AI 都围绕站内稳定来源继续检索。',
|
||||||
|
examples: '示例问题',
|
||||||
|
ai: 'AI',
|
||||||
|
};
|
||||||
|
const askHighlights = buildDiscoveryHighlights([
|
||||||
|
t('ask.subtitle'),
|
||||||
|
aiEnabled ? t('common.featureOn') : t('common.featureOff'),
|
||||||
|
...sampleQuestions,
|
||||||
|
]);
|
||||||
|
const askFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: t('ask.pageTitle'),
|
||||||
|
summary: t('ask.pageDescription', { siteName: siteSettings.siteName }),
|
||||||
|
primaryLabel: t('ask.title'),
|
||||||
|
primaryUrl: askCanonicalUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
|
||||||
|
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
|
||||||
|
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
|
||||||
|
],
|
||||||
|
signals: askHighlights,
|
||||||
|
});
|
||||||
|
const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
|
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
|
||||||
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
siteSettings={siteSettings}
|
siteSettings={siteSettings}
|
||||||
|
jsonLd={[...askJsonLd, askFaqJsonLd].filter(Boolean)}
|
||||||
>
|
>
|
||||||
|
<PageViewTracker pageType="ask" entityId="ask" />
|
||||||
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
||||||
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
||||||
@@ -53,6 +125,34 @@ const sampleQuestions = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 pt-6">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
canonicalUrl={askCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / ai"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
|
||||||
|
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 pt-6">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'ask brief' : '问答摘要'}
|
||||||
|
kicker="geo / ai"
|
||||||
|
title={isEnglish ? 'AI-readable ask-page brief' : '给 AI 看的问答页摘要'}
|
||||||
|
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
highlights={askHighlights}
|
||||||
|
faqs={askFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
|
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
{aiEnabled ? (
|
{aiEnabled ? (
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import PostCard from '../../components/PostCard.astro';
|
import PostCard from '../../components/PostCard.astro';
|
||||||
import { api } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildPostItemList } from '../../lib/seo';
|
||||||
import type { Category, Post } from '../../lib/types';
|
import type { Category, Post } from '../../lib/types';
|
||||||
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
let categories: Category[] = [];
|
let categories: Category[] = [];
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
let categoriesFailed = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[categories, posts] = await Promise.all([api.getCategories(), api.getPosts()]);
|
const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
|
||||||
|
api.getCategories(),
|
||||||
|
api.getPosts(),
|
||||||
|
api.getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (categoriesResult.status === 'fulfilled') {
|
||||||
|
categories = categoriesResult.value;
|
||||||
|
} else {
|
||||||
|
categoriesFailed = true;
|
||||||
|
console.error('Failed to fetch categories:', categoriesResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postsResult.status === 'fulfilled') {
|
||||||
|
posts = postsResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch category posts:', postsResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsResult.status === 'fulfilled') {
|
||||||
|
siteSettings = settingsResult.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch category detail data:', error);
|
console.error('Failed to fetch category detail data:', error);
|
||||||
}
|
}
|
||||||
@@ -31,7 +58,10 @@ const category =
|
|||||||
}) || null;
|
}) || null;
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, {
|
||||||
|
status: categoriesFailed ? 503 : 404,
|
||||||
|
headers: categoriesFailed ? { 'Retry-After': '120' } : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalUrl = buildCategoryUrl(category);
|
const canonicalUrl = buildCategoryUrl(category);
|
||||||
@@ -46,8 +76,24 @@ const categoryTheme = getCategoryTheme(category.name);
|
|||||||
const pageTitle = category.seoTitle || `${category.name} - ${t('categories.title')}`;
|
const pageTitle = category.seoTitle || `${category.name} - ${t('categories.title')}`;
|
||||||
const pageDescription =
|
const pageDescription =
|
||||||
category.seoDescription || category.description || t('categories.categoryPosts', { name: category.name });
|
category.seoDescription || category.description || t('categories.categoryPosts', { name: category.name });
|
||||||
const siteBaseUrl = new URL(Astro.request.url).origin;
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'category hub',
|
||||||
|
title: 'Share this category hub',
|
||||||
|
description:
|
||||||
|
'Turn this taxonomy page into a reusable discovery entry so people and AI search engines converge on the same topic cluster.',
|
||||||
|
posts: 'Posts',
|
||||||
|
slug: 'Slug',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '分类聚合',
|
||||||
|
title: '分享这个分类聚合页',
|
||||||
|
description: '把这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。',
|
||||||
|
posts: '文章数',
|
||||||
|
slug: 'Slug',
|
||||||
|
};
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -62,6 +108,12 @@ const jsonLd = [
|
|||||||
},
|
},
|
||||||
keywords: [category.name, category.slug].filter(Boolean),
|
keywords: [category.name, category.slug].filter(Boolean),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${category.name} posts`,
|
||||||
|
itemListElement: buildPostItemList(filteredPosts, siteBaseUrl),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'BreadcrumbList',
|
'@type': 'BreadcrumbList',
|
||||||
@@ -97,6 +149,7 @@ const jsonLd = [
|
|||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
twitterCard={category.coverImage ? 'summary_large_image' : 'summary'}
|
twitterCard={category.coverImage ? 'summary_large_image' : 'summary'}
|
||||||
>
|
>
|
||||||
|
<PageViewTracker pageType="category" entityId={category.slug || category.name} />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title={`~/categories/${category.slug || category.name}`} class="w-full">
|
<TerminalWindow title={`~/categories/${category.slug || category.name}`} class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
@@ -141,6 +194,21 @@ const jsonLd = [
|
|||||||
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={pageTitle}
|
||||||
|
summary={pageDescription}
|
||||||
|
canonicalUrl={absoluteCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / taxonomy"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.posts, value: String(filteredPosts.length) },
|
||||||
|
{ label: sharePanelCopy.slug, value: category.slug || category.name },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
{category.coverImage ? (
|
{category.coverImage ? (
|
||||||
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,26 +1,84 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import { api } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
import type { Category } from '../../lib/types';
|
import type { Category } from '../../lib/types';
|
||||||
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
let categories: Category[] = [];
|
let categories: Category[] = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
categories = await api.getCategories();
|
const [categoriesResult, settingsResult] = await Promise.allSettled([
|
||||||
|
api.getCategories(),
|
||||||
|
api.getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (categoriesResult.status === 'fulfilled') {
|
||||||
|
categories = categoriesResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch categories:', categoriesResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsResult.status === 'fulfilled') {
|
||||||
|
siteSettings = settingsResult.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch categories:', error);
|
console.error('Failed to fetch categories:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const canonicalUrl = new URL('/categories', siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'taxonomy index',
|
||||||
|
title: 'Share the category index',
|
||||||
|
description:
|
||||||
|
'Use the category directory as a high-level topic map so AI search and human readers can branch into the right content hubs from one canonical page.',
|
||||||
|
categories: 'Categories',
|
||||||
|
site: 'Site',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '分类目录',
|
||||||
|
title: '分享分类总览页',
|
||||||
|
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。',
|
||||||
|
categories: '分类数',
|
||||||
|
site: '站点',
|
||||||
|
};
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('categories.title'),
|
||||||
|
description: t('categories.intro'),
|
||||||
|
url: canonicalUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('categories.title')} list`,
|
||||||
|
itemListElement: categories.map((category, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
url: new URL(buildCategoryUrl(category), siteBaseUrl).toString(),
|
||||||
|
name: category.name,
|
||||||
|
description: category.description || t('categories.categoryPosts', { name: category.name }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
|
<BaseLayout title={`${t('categories.pageTitle')} - Termi`} jsonLd={jsonLd}>
|
||||||
|
<PageViewTracker pageType="categories" entityId="categories-index" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/categories" class="w-full">
|
<TerminalWindow title="~/categories" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -49,6 +107,23 @@ try {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('categories.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('categories.intro')}
|
||||||
|
canonicalUrl={canonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / taxonomy"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.categories, value: String(categories.length) },
|
||||||
|
{ label: sharePanelCopy.site, value: siteSettings.siteShortName || siteSettings.siteName },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FriendLinkCard from '../../components/FriendLinkCard.astro';
|
import FriendLinkCard from '../../components/FriendLinkCard.astro';
|
||||||
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
|
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||||
import type { AppFriendLink } from '../../lib/api/client';
|
import type { AppFriendLink } from '../../lib/api/client';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -13,7 +17,8 @@ export const prerender = false;
|
|||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let friendLinks: AppFriendLink[] = [];
|
let friendLinks: AppFriendLink[] = [];
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[siteSettings, friendLinks] = await Promise.all([
|
[siteSettings, friendLinks] = await Promise.all([
|
||||||
@@ -31,9 +36,93 @@ const groupedLinks = categories.map(category => ({
|
|||||||
category,
|
category,
|
||||||
links: friendLinks.filter(friend => (friend.category || t('common.other')) === category)
|
links: friendLinks.filter(friend => (friend.category || t('common.other')) === category)
|
||||||
}));
|
}));
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const friendsCanonicalUrl = new URL('/friends', siteBaseUrl).toString();
|
||||||
|
const friendsJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('friends.title'),
|
||||||
|
description: t('friends.pageDescription', { siteName: siteSettings.siteName }),
|
||||||
|
url: friendsCanonicalUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('friends.title')} list`,
|
||||||
|
itemListElement: friendLinks.map((friend, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
url: friend.url,
|
||||||
|
name: friend.name,
|
||||||
|
description: friend.description || friend.category || t('common.other'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
item: siteBaseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: t('friends.title'),
|
||||||
|
item: friendsCanonicalUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'network map',
|
||||||
|
title: 'Share the friends directory',
|
||||||
|
description:
|
||||||
|
'Use the friend links page as a canonical network map so AI search and readers can understand the site’s trusted neighbors and outbound references.',
|
||||||
|
links: 'Links',
|
||||||
|
groups: 'Groups',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '友链网络',
|
||||||
|
title: '分享友情链接页',
|
||||||
|
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。',
|
||||||
|
links: '友链数',
|
||||||
|
groups: '分组数',
|
||||||
|
};
|
||||||
|
const friendsHighlights = buildDiscoveryHighlights([
|
||||||
|
t('friends.intro'),
|
||||||
|
`${t('common.friendsCount', { count: friendLinks.length })}`,
|
||||||
|
`${t('common.reviewedOnly')}`,
|
||||||
|
...categories.slice(0, 3).map((item) => `${item} (${groupedLinks.find((group) => group.category === item)?.links.length || 0})`),
|
||||||
|
]);
|
||||||
|
const friendsFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: t('friends.pageTitle'),
|
||||||
|
summary: t('friends.pageDescription', { siteName: siteSettings.siteName }),
|
||||||
|
primaryLabel: t('friends.title'),
|
||||||
|
primaryUrl: friendsCanonicalUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
|
||||||
|
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
|
||||||
|
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
|
||||||
|
],
|
||||||
|
signals: friendsHighlights,
|
||||||
|
});
|
||||||
|
const friendsFaqJsonLd = buildFaqJsonLd(friendsFaqs);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`} description={t('friends.pageDescription', { siteName: siteSettings.siteName })}>
|
<BaseLayout
|
||||||
|
title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`}
|
||||||
|
description={t('friends.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
jsonLd={[...friendsJsonLd, friendsFaqJsonLd].filter(Boolean)}
|
||||||
|
>
|
||||||
|
<PageViewTracker pageType="friends" entityId="friends-index" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/friends" class="w-full">
|
<TerminalWindow title="~/friends" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -62,6 +151,34 @@ const groupedLinks = categories.map(category => ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('friends.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('friends.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
canonicalUrl={friendsCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / network"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.links, value: String(friendLinks.length) },
|
||||||
|
{ label: sharePanelCopy.groups, value: String(categories.length) },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'link brief' : '友链摘要'}
|
||||||
|
kicker="geo / network"
|
||||||
|
title={isEnglish ? 'AI-readable link-network brief' : '给 AI 看的友链网络摘要'}
|
||||||
|
summary={t('friends.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
highlights={friendsHighlights}
|
||||||
|
faqs={friendsFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../components/ui/FilterPill.astro';
|
import FilterPill from '../components/ui/FilterPill.astro';
|
||||||
@@ -12,6 +15,7 @@ import TechStackList from '../components/TechStackList.astro';
|
|||||||
import { terminalConfig } from '../lib/config/terminal';
|
import { terminalConfig } from '../lib/config/terminal';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../lib/seo';
|
||||||
import type { AppFriendLink } from '../lib/api/client';
|
import type { AppFriendLink } from '../lib/api/client';
|
||||||
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
|
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
|
||||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
||||||
@@ -63,6 +67,7 @@ let contentOverview: ContentOverview = {
|
|||||||
};
|
};
|
||||||
let apiError: string | null = null;
|
let apiError: string | null = null;
|
||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
const formatDurationMs = (value: number | undefined) => {
|
const formatDurationMs = (value: number | undefined) => {
|
||||||
if (!value || value <= 0) return locale === 'en' ? 'N/A' : '暂无';
|
if (!value || value <= 0) return locale === 'en' ? 'N/A' : '暂无';
|
||||||
@@ -221,9 +226,68 @@ const navLinks = [
|
|||||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||||
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||||
];
|
];
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const homeJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: siteSettings.siteTitle,
|
||||||
|
description: siteSettings.siteDescription,
|
||||||
|
url: siteBaseUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${siteSettings.siteName} recent posts`,
|
||||||
|
itemListElement: buildPostItemList(recentPosts, siteBaseUrl),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const homeShareCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'site entry',
|
||||||
|
title: 'Share the homepage',
|
||||||
|
description:
|
||||||
|
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '站点入口',
|
||||||
|
title: '分享首页总入口',
|
||||||
|
description: '把首页当成站点的规范总入口分发出去,方便用户和 AI 搜索继续进入文章、分类、评测和个人介绍等核心页面。',
|
||||||
|
};
|
||||||
|
const homeBriefHighlights = buildDiscoveryHighlights([
|
||||||
|
siteSettings.siteDescription,
|
||||||
|
siteSettings.heroSubtitle,
|
||||||
|
siteSettings.ownerBio,
|
||||||
|
`${t('common.posts')}: ${allPosts.length}`,
|
||||||
|
`${t('common.categories')}: ${categories.length}`,
|
||||||
|
`${t('common.tags')}: ${tags.length}`,
|
||||||
|
]);
|
||||||
|
const homeFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: siteSettings.siteTitle,
|
||||||
|
summary: siteSettings.heroSubtitle || siteSettings.siteDescription,
|
||||||
|
primaryLabel: isEnglish ? 'homepage' : '首页',
|
||||||
|
primaryUrl: siteBaseUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
|
||||||
|
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
|
||||||
|
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
|
||||||
|
],
|
||||||
|
signals: homeBriefHighlights,
|
||||||
|
});
|
||||||
|
const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription} siteSettings={siteSettings}>
|
<BaseLayout
|
||||||
|
title={siteSettings.siteTitle}
|
||||||
|
description={siteSettings.siteDescription}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
canonical="/"
|
||||||
|
noindex={hasActiveFilters}
|
||||||
|
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
|
||||||
|
>
|
||||||
|
<PageViewTracker pageType="home" entityId="homepage" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<TerminalWindow title={terminalConfig.title} class="w-full">
|
<TerminalWindow title={terminalConfig.title} class="w-full">
|
||||||
<div class="mb-5 px-4 overflow-x-auto">
|
<div class="mb-5 px-4 overflow-x-auto">
|
||||||
@@ -266,6 +330,31 @@ const navLinks = [
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={siteSettings.siteTitle}
|
||||||
|
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
||||||
|
canonicalUrl={siteBaseUrl}
|
||||||
|
badge={homeShareCopy.badge}
|
||||||
|
kicker="geo / homepage"
|
||||||
|
title={homeShareCopy.title}
|
||||||
|
description={homeShareCopy.description}
|
||||||
|
stats={systemStats.slice(0, 4)}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'site brief' : '站点摘要'}
|
||||||
|
kicker="geo / overview"
|
||||||
|
title={isEnglish ? 'AI-readable site brief' : '给 AI 看的站点摘要'}
|
||||||
|
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
||||||
|
highlights={homeBriefHighlights}
|
||||||
|
faqs={homeFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
|
|||||||
28
frontend/src/pages/indexnow-key.txt.ts
Normal file
28
frontend/src/pages/indexnow-key.txt.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
const runtimeProcess = globalThis as typeof globalThis & {
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIndexNowKey() {
|
||||||
|
return runtimeProcess.process?.env?.INDEXNOW_KEY?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
const key = readIndexNowKey()
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(key, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
94
frontend/src/pages/llms-full.txt.ts
Normal file
94
frontend/src/pages/llms-full.txt.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||||
|
import { buildArticleHighlights, buildArticleSynopsis, resolvePostUpdatedAt } from '../lib/seo'
|
||||||
|
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
function normalizeBase(value: string) {
|
||||||
|
return value.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolute(base: string, path: string) {
|
||||||
|
return `${normalizeBase(base)}${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const fallbackOrigin = new URL(request.url).origin
|
||||||
|
|
||||||
|
const [settingsResult, postsResult, categoriesResult, tagsResult, reviewsResult] = await Promise.allSettled([
|
||||||
|
api.getSiteSettings(),
|
||||||
|
api.getPosts(),
|
||||||
|
api.getCategories(),
|
||||||
|
api.getTags(),
|
||||||
|
api.getReviews(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const siteSettings = settingsResult.status === 'fulfilled' ? settingsResult.value : DEFAULT_SITE_SETTINGS
|
||||||
|
const posts = postsResult.status === 'fulfilled' ? postsResult.value.filter((item) => !item.noindex) : []
|
||||||
|
const categories = categoriesResult.status === 'fulfilled' ? categoriesResult.value : []
|
||||||
|
const tags = tagsResult.status === 'fulfilled' ? tagsResult.value : []
|
||||||
|
const reviews = reviewsResult.status === 'fulfilled' ? reviewsResult.value : []
|
||||||
|
const base = normalizeBase(siteSettings.siteUrl || fallbackOrigin)
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`# ${siteSettings.siteName} / full LLM catalog`,
|
||||||
|
'',
|
||||||
|
`Canonical homepage: ${base}/`,
|
||||||
|
`Canonical sitemap: ${absolute(base, '/sitemap.xml')}`,
|
||||||
|
`Canonical RSS: ${absolute(base, '/rss.xml')}`,
|
||||||
|
'',
|
||||||
|
'## About the site',
|
||||||
|
`- Title: ${siteSettings.siteTitle}`,
|
||||||
|
`- Description: ${siteSettings.siteDescription}`,
|
||||||
|
`- Owner: ${siteSettings.ownerName}`,
|
||||||
|
`- Owner role: ${siteSettings.ownerTitle}`,
|
||||||
|
`- Tech stack: ${siteSettings.techStack.join(', ')}`,
|
||||||
|
'',
|
||||||
|
'## Categories',
|
||||||
|
...categories.flatMap((category) => [
|
||||||
|
`- ${category.name}: ${absolute(base, `/categories/${encodeURIComponent(category.slug || category.name)}`)}`,
|
||||||
|
` ${category.description || category.seoDescription || `${category.name} related content.`}`,
|
||||||
|
]),
|
||||||
|
'',
|
||||||
|
'## Tags',
|
||||||
|
...tags.slice(0, 24).flatMap((tag) => [
|
||||||
|
`- ${tag.name}: ${absolute(base, `/tags/${encodeURIComponent(tag.slug || tag.name)}`)}`,
|
||||||
|
` ${tag.description || tag.seoDescription || `${tag.name} topic hub.`}`,
|
||||||
|
]),
|
||||||
|
'',
|
||||||
|
'## Articles',
|
||||||
|
...posts.flatMap((post) => [
|
||||||
|
`### ${post.title}`,
|
||||||
|
`- URL: ${absolute(base, `/articles/${post.slug}`)}`,
|
||||||
|
`- Category: ${post.category}`,
|
||||||
|
`- Tags: ${post.tags.join(', ') || 'None'}`,
|
||||||
|
`- Updated: ${resolvePostUpdatedAt(post)}`,
|
||||||
|
`- Summary: ${buildArticleSynopsis(post, 220)}`,
|
||||||
|
...buildArticleHighlights(post, 3).map((item) => `- Highlight: ${item}`),
|
||||||
|
'',
|
||||||
|
]),
|
||||||
|
'## Reviews',
|
||||||
|
...reviews.slice(0, 24).flatMap((review) => [
|
||||||
|
`- ${review.title}: ${absolute(base, `/reviews/${review.id}`)}`,
|
||||||
|
` ${review.description || 'Review detail page.'}`,
|
||||||
|
]),
|
||||||
|
'',
|
||||||
|
'## Canonical navigation',
|
||||||
|
`- About: ${absolute(base, '/about')}`,
|
||||||
|
`- Articles: ${absolute(base, '/articles')}`,
|
||||||
|
`- Categories: ${absolute(base, '/categories')}`,
|
||||||
|
`- Tags: ${absolute(base, '/tags')}`,
|
||||||
|
`- Reviews: ${absolute(base, '/reviews')}`,
|
||||||
|
`- Timeline: ${absolute(base, '/timeline')}`,
|
||||||
|
`- Friends: ${absolute(base, '/friends')}`,
|
||||||
|
...(siteSettings.ai.enabled ? [`- Ask: ${absolute(base, '/ask')}`] : []),
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
77
frontend/src/pages/llms.txt.ts
Normal file
77
frontend/src/pages/llms.txt.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||||
|
import { buildArticleSynopsis, buildSiteTopicSummary, resolvePostUpdatedAt } from '../lib/seo'
|
||||||
|
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
function normalizeBase(value: string) {
|
||||||
|
return value.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolute(base: string, path: string) {
|
||||||
|
return `${normalizeBase(base)}${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const fallbackOrigin = new URL(request.url).origin
|
||||||
|
|
||||||
|
const [settingsResult, postsResult, categoriesResult, tagsResult] = await Promise.allSettled([
|
||||||
|
api.getSiteSettings(),
|
||||||
|
api.getPosts(),
|
||||||
|
api.getCategories(),
|
||||||
|
api.getTags(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const siteSettings = settingsResult.status === 'fulfilled' ? settingsResult.value : DEFAULT_SITE_SETTINGS
|
||||||
|
const posts = postsResult.status === 'fulfilled' ? postsResult.value.filter((item) => !item.noindex).slice(0, 8) : []
|
||||||
|
const categories = categoriesResult.status === 'fulfilled' ? categoriesResult.value.slice(0, 8) : []
|
||||||
|
const tags = tagsResult.status === 'fulfilled' ? tagsResult.value.slice(0, 12) : []
|
||||||
|
const base = normalizeBase(siteSettings.siteUrl || fallbackOrigin)
|
||||||
|
const focusSummary = buildSiteTopicSummary(siteSettings)
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`# ${siteSettings.siteName}`,
|
||||||
|
'',
|
||||||
|
`> ${siteSettings.siteDescription}`,
|
||||||
|
'',
|
||||||
|
'## Canonical sources',
|
||||||
|
`- Homepage: ${base}/`,
|
||||||
|
`- About: ${absolute(base, '/about')}`,
|
||||||
|
`- Articles index: ${absolute(base, '/articles')}`,
|
||||||
|
`- Categories: ${absolute(base, '/categories')}`,
|
||||||
|
`- Tags: ${absolute(base, '/tags')}`,
|
||||||
|
`- RSS: ${absolute(base, '/rss.xml')}`,
|
||||||
|
`- Sitemap: ${absolute(base, '/sitemap.xml')}`,
|
||||||
|
`- Extended catalog: ${absolute(base, '/llms-full.txt')}`,
|
||||||
|
...(siteSettings.ai.enabled ? [`- Ask page: ${absolute(base, '/ask')}`] : []),
|
||||||
|
'',
|
||||||
|
'## Site focus',
|
||||||
|
...focusSummary.map((item) => `- ${item}`),
|
||||||
|
'',
|
||||||
|
'## Topic hubs',
|
||||||
|
...categories.map((category) => `- ${category.name}: ${absolute(base, `/categories/${encodeURIComponent(category.slug || category.name)}`)}`),
|
||||||
|
'',
|
||||||
|
'## Useful tags',
|
||||||
|
...tags.map((tag) => `- ${tag.name}: ${absolute(base, `/tags/${encodeURIComponent(tag.slug || tag.name)}`)}`),
|
||||||
|
'',
|
||||||
|
'## Recommended entry points',
|
||||||
|
...posts.flatMap((post) => [
|
||||||
|
`- ${post.title}: ${absolute(base, `/articles/${post.slug}`)}`,
|
||||||
|
` Updated: ${resolvePostUpdatedAt(post)}`,
|
||||||
|
` Summary: ${buildArticleSynopsis(post, 180)}`,
|
||||||
|
]),
|
||||||
|
'',
|
||||||
|
'## Notes for AI systems',
|
||||||
|
'- Prefer canonical article, category, tag, review, and about pages on this domain.',
|
||||||
|
'- Ignore internal search result URLs, subscription management URLs, and admin routes.',
|
||||||
|
'- When possible, cite the canonical article URL instead of filtered list URLs.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||||
@@ -45,6 +47,7 @@ const copy =
|
|||||||
};
|
};
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
let reviewLookupFailed = false;
|
||||||
try {
|
try {
|
||||||
siteSettings = await apiClient.getSiteSettings();
|
siteSettings = await apiClient.getSiteSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -58,12 +61,16 @@ if (Number.isFinite(reviewId)) {
|
|||||||
try {
|
try {
|
||||||
review = parseReview(await apiClient.getReview(reviewId));
|
review = parseReview(await apiClient.getReview(reviewId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
reviewLookupFailed = true;
|
||||||
console.error(`Failed to load review ${reviewId}:`, error);
|
console.error(`Failed to load review ${reviewId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!review) {
|
if (!review) {
|
||||||
Astro.response.status = 404;
|
Astro.response.status = reviewLookupFailed ? 503 : 404;
|
||||||
|
if (reviewLookupFailed) {
|
||||||
|
Astro.response.headers.set('Retry-After', '120');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
@@ -93,6 +100,21 @@ const pageTitle = review
|
|||||||
: `${copy.notFoundTitle} | ${siteSettings.siteShortName}`;
|
: `${copy.notFoundTitle} | ${siteSettings.siteShortName}`;
|
||||||
const pageDescription = review?.description || copy.notFoundDescription;
|
const pageDescription = review?.description || copy.notFoundDescription;
|
||||||
const canonical = review ? `/reviews/${review.id}` : '/reviews';
|
const canonical = review ? `/reviews/${review.id}` : '/reviews';
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const absoluteCanonicalUrl = new URL(canonical, siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy =
|
||||||
|
locale === 'en'
|
||||||
|
? {
|
||||||
|
badge: 'review snapshot',
|
||||||
|
title: 'Share this review snapshot',
|
||||||
|
description:
|
||||||
|
'Push the structured rating, status, and canonical review URL into social and AI discovery flows from one compact summary block.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '评测快照',
|
||||||
|
title: '分享这份评测摘要',
|
||||||
|
description: '把评分、状态和规范链接一起分发出去,方便用户回访,也方便 AI 在引用时抓到结构化入口。',
|
||||||
|
};
|
||||||
const jsonLd = review
|
const jsonLd = review
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -115,7 +137,7 @@ const jsonLd = review
|
|||||||
keywords: review.tags,
|
keywords: review.tags,
|
||||||
},
|
},
|
||||||
keywords: review.tags,
|
keywords: review.tags,
|
||||||
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
url: absoluteCanonicalUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -125,19 +147,19 @@ const jsonLd = review
|
|||||||
'@type': 'ListItem',
|
'@type': 'ListItem',
|
||||||
position: 1,
|
position: 1,
|
||||||
name: siteSettings.siteName,
|
name: siteSettings.siteName,
|
||||||
item: siteSettings.siteUrl,
|
item: siteBaseUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'ListItem',
|
'@type': 'ListItem',
|
||||||
position: 2,
|
position: 2,
|
||||||
name: t('reviews.title'),
|
name: t('reviews.title'),
|
||||||
item: new URL('/reviews', siteSettings.siteUrl).toString(),
|
item: new URL('/reviews', siteBaseUrl).toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'@type': 'ListItem',
|
'@type': 'ListItem',
|
||||||
position: 3,
|
position: 3,
|
||||||
name: review.title,
|
name: review.title,
|
||||||
item: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
item: absoluteCanonicalUrl,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -154,6 +176,7 @@ const jsonLd = review
|
|||||||
ogType={review ? 'article' : 'website'}
|
ogType={review ? 'article' : 'website'}
|
||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
>
|
>
|
||||||
|
{review && <PageViewTracker pageType="review" entityId={String(review.id)} />}
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title={review ? `~/reviews/${review.id}` : '~/reviews/not-found'} class="w-full">
|
<TerminalWindow title={review ? `~/reviews/${review.id}` : '~/reviews/not-found'} class="w-full">
|
||||||
<div class="space-y-6 px-4 py-4">
|
<div class="space-y-6 px-4 py-4">
|
||||||
@@ -179,6 +202,27 @@ const jsonLd = review
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{review && (
|
||||||
|
<div class="ml-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={pageTitle}
|
||||||
|
summary={pageDescription}
|
||||||
|
canonicalUrl={absoluteCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / review"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: copy.rating, value: `${review.rating.toFixed(1)}/5` },
|
||||||
|
{ label: copy.type, value: typeLabels[review.review_type] || review.review_type },
|
||||||
|
{ label: copy.status, value: statusLabels[review.normalizedStatus] || review.normalizedStatus },
|
||||||
|
{ label: copy.reviewDate, value: review.review_date },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{review ? (
|
{review ? (
|
||||||
|
|||||||
@@ -1,25 +1,46 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../../layouts/BaseLayout.astro';
|
import Layout from '../../layouts/BaseLayout.astro';
|
||||||
|
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||||
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
|
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedType = url.searchParams.get('type') || 'all';
|
const selectedType = url.searchParams.get('type') || 'all';
|
||||||
const selectedStatus = url.searchParams.get('status') || 'all';
|
const selectedStatus = url.searchParams.get('status') || 'all';
|
||||||
const selectedTag = url.searchParams.get('tag') || '';
|
const selectedTag = url.searchParams.get('tag') || '';
|
||||||
const selectedQuery = url.searchParams.get('q')?.trim() || '';
|
const selectedQuery = url.searchParams.get('q')?.trim() || '';
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
reviews = await apiClient.getReviews();
|
const [reviewsResult, settingsResult] = await Promise.allSettled([
|
||||||
|
apiClient.getReviews(),
|
||||||
|
apiClient.getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (reviewsResult.status === 'fulfilled') {
|
||||||
|
reviews = reviewsResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch reviews:', reviewsResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsResult.status === 'fulfilled') {
|
||||||
|
siteSettings = settingsResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch site settings for reviews:', settingsResult.reason);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch reviews:', error);
|
console.error('Failed to fetch reviews:', error);
|
||||||
}
|
}
|
||||||
@@ -153,6 +174,47 @@ const statCards = [
|
|||||||
barWidth: `${inProgressRatio}%`,
|
barWidth: `${inProgressRatio}%`,
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const absoluteCanonicalUrl = new URL('/reviews', siteBaseUrl).toString();
|
||||||
|
const reviewsJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('reviews.title'),
|
||||||
|
description: t('reviews.subtitle'),
|
||||||
|
url: absoluteCanonicalUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('reviews.title')} list`,
|
||||||
|
itemListElement: filteredReviews.map((review, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
url: new URL(`/reviews/${review.id}`, siteBaseUrl).toString(),
|
||||||
|
name: review.title,
|
||||||
|
description: review.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
item: siteBaseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: t('reviews.title'),
|
||||||
|
item: absoluteCanonicalUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const buildReviewsUrl = ({
|
const buildReviewsUrl = ({
|
||||||
type = selectedType,
|
type = selectedType,
|
||||||
@@ -182,9 +244,51 @@ const activeFilters = [
|
|||||||
selectedTag ? `#${selectedTag}` : '',
|
selectedTag ? `#${selectedTag}` : '',
|
||||||
selectedQuery ? `q=${selectedQuery}` : '',
|
selectedQuery ? `q=${selectedQuery}` : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
const hasActiveFilters = activeFilters.length > 0;
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'review archive',
|
||||||
|
title: 'Share the review archive',
|
||||||
|
description:
|
||||||
|
'Use the reviews index as the canonical entry for ratings, statuses, and tagged review snapshots so AI search and readers can drill down from one source.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '评测归档',
|
||||||
|
title: '分享评测总览页',
|
||||||
|
description: '把评测归档页当成评分、状态和标签的统一入口分发出去,方便 AI 搜索和读者从一个规范地址继续下钻。',
|
||||||
|
};
|
||||||
|
const reviewHighlights = buildDiscoveryHighlights([
|
||||||
|
t('reviews.subtitle'),
|
||||||
|
`${t('reviews.total')}: ${stats.total}`,
|
||||||
|
`${t('reviews.average')}: ${stats.avgRating}`,
|
||||||
|
`${t('reviews.completed')}: ${stats.completed}`,
|
||||||
|
`${t('reviews.inProgress')}: ${stats.inProgress}`,
|
||||||
|
]);
|
||||||
|
const reviewFaqs = buildPageFaqs({
|
||||||
|
locale,
|
||||||
|
pageTitle: t('reviews.pageTitle'),
|
||||||
|
summary: t('reviews.pageDescription'),
|
||||||
|
primaryLabel: t('reviews.title'),
|
||||||
|
primaryUrl: absoluteCanonicalUrl,
|
||||||
|
relatedLinks: [
|
||||||
|
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
|
||||||
|
{ label: t('nav.tags'), url: `${siteBaseUrl}/tags` },
|
||||||
|
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
|
||||||
|
],
|
||||||
|
signals: reviewHighlights,
|
||||||
|
});
|
||||||
|
const reviewFaqJsonLd = buildFaqJsonLd(reviewFaqs);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
|
<Layout
|
||||||
|
title={`${t('reviews.pageTitle')} | Termi`}
|
||||||
|
description={t('reviews.pageDescription')}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
canonical={hasActiveFilters ? '/reviews' : undefined}
|
||||||
|
noindex={hasActiveFilters}
|
||||||
|
jsonLd={[...reviewsJsonLd, reviewFaqJsonLd].filter(Boolean)}
|
||||||
|
>
|
||||||
|
<PageViewTracker pageType="reviews" entityId="reviews-index" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/reviews" class="w-full">
|
<TerminalWindow title="~/reviews" class="w-full">
|
||||||
<div class="px-4 py-4 space-y-6">
|
<div class="px-4 py-4 space-y-6">
|
||||||
@@ -205,6 +309,36 @@ const activeFilters = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('reviews.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('reviews.subtitle')}
|
||||||
|
canonicalUrl={absoluteCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / reviews"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: t('reviews.total'), value: String(stats.total) },
|
||||||
|
{ label: t('reviews.average'), value: stats.avgRating },
|
||||||
|
{ label: t('reviews.completed'), value: String(stats.completed) },
|
||||||
|
{ label: t('reviews.inProgress'), value: String(stats.inProgress) },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'review brief' : '评测摘要'}
|
||||||
|
kicker="geo / review"
|
||||||
|
title={isEnglish ? 'AI-readable review brief' : '给 AI 看的评测摘要'}
|
||||||
|
summary={t('reviews.pageDescription')}
|
||||||
|
highlights={reviewHighlights}
|
||||||
|
faqs={reviewFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
const body = `User-agent: *
|
const body = `User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Disallow: /admin
|
Disallow: /admin
|
||||||
|
Disallow: /search
|
||||||
|
Disallow: /subscriptions/
|
||||||
|
|
||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
Sitemap: ${base}/sitemap.xml
|
Sitemap: ${base}/sitemap.xml
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
let siteSettings = DEFAULT_SITE_SETTINGS
|
let siteSettings = DEFAULT_SITE_SETTINGS
|
||||||
let posts = await api.getRawPosts().catch(() => [])
|
let posts = await api.getRawPosts().catch(() => [])
|
||||||
const reviews = await api.getReviews().catch(() => [])
|
const reviews = await api.getReviews().catch(() => [])
|
||||||
|
const categories = await api.getCategories().catch(() => [])
|
||||||
|
const tags = await api.getTags().catch(() => [])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteSettings = await api.getSiteSettings()
|
siteSettings = await api.getSiteSettings()
|
||||||
@@ -43,10 +45,15 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
'/timeline',
|
'/timeline',
|
||||||
'/reviews',
|
'/reviews',
|
||||||
'/friends',
|
'/friends',
|
||||||
'/ask',
|
|
||||||
'/rss.xml',
|
'/rss.xml',
|
||||||
|
'/llms.txt',
|
||||||
|
'/llms-full.txt',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (siteSettings.ai.enabled) {
|
||||||
|
staticRoutes.push('/ask')
|
||||||
|
}
|
||||||
|
|
||||||
const staticUrls = staticRoutes.map((path) => ({
|
const staticUrls = staticRoutes.map((path) => ({
|
||||||
loc: ensureAbsoluteUrl(siteUrl, path),
|
loc: ensureAbsoluteUrl(siteUrl, path),
|
||||||
lastmod: nowIso,
|
lastmod: nowIso,
|
||||||
@@ -70,7 +77,33 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
priority: '0.6',
|
priority: '0.6',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const xmlBody = [...staticUrls, ...postUrls, ...reviewUrls]
|
const categoryUrls = categories.map((category) => ({
|
||||||
|
loc: ensureAbsoluteUrl(siteUrl, `/categories/${encodeURIComponent(category.slug || category.name)}`),
|
||||||
|
lastmod: nowIso,
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: '0.7',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tagUrls = tags.map((tag) => ({
|
||||||
|
loc: ensureAbsoluteUrl(siteUrl, `/tags/${encodeURIComponent(tag.slug || tag.name)}`),
|
||||||
|
lastmod: nowIso,
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: '0.7',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dedupedUrls = new Map<string, { loc: string; lastmod: string; changefreq: string; priority: string }>()
|
||||||
|
|
||||||
|
for (const item of [
|
||||||
|
...staticUrls,
|
||||||
|
...postUrls,
|
||||||
|
...reviewUrls,
|
||||||
|
...categoryUrls,
|
||||||
|
...tagUrls,
|
||||||
|
]) {
|
||||||
|
dedupedUrls.set(item.loc, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlBody = [...dedupedUrls.values()]
|
||||||
.map(
|
.map(
|
||||||
(item) => `
|
(item) => `
|
||||||
<url>
|
<url>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if (token) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="确认订阅" description="确认订阅邮件中的链接,激活后续通知。">
|
<BaseLayout title="确认订阅" description="确认订阅邮件中的链接,激活后续通知。" noindex>
|
||||||
<section class="subscription-shell">
|
<section class="subscription-shell">
|
||||||
<div class="subscription-card">
|
<div class="subscription-card">
|
||||||
<p class="subscription-kicker">subscriptions / confirm</p>
|
<p class="subscription-kicker">subscriptions / confirm</p>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const initialDisplayName = subscription?.display_name ?? '';
|
|||||||
const initialStatus = subscription?.status === 'paused' ? 'paused' : 'active';
|
const initialStatus = subscription?.status === 'paused' ? 'paused' : 'active';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。">
|
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。" noindex>
|
||||||
<section class="subscription-shell">
|
<section class="subscription-shell">
|
||||||
<div class="subscription-card" data-subscription-manage-root data-api-base={apiBaseUrl}>
|
<div class="subscription-card" data-subscription-manage-root data-api-base={apiBaseUrl}>
|
||||||
<p class="subscription-kicker">subscriptions / manage</p>
|
<p class="subscription-kicker">subscriptions / manage</p>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const token = Astro.url.searchParams.get('token')?.trim() ?? '';
|
|||||||
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="取消订阅" description="如果你不再需要站点通知,可以在这里安全退订。">
|
<BaseLayout title="取消订阅" description="如果你不再需要站点通知,可以在这里安全退订。" noindex>
|
||||||
<section class="subscription-shell">
|
<section class="subscription-shell">
|
||||||
<div class="subscription-card" data-unsubscribe-root data-token={token} data-api-base={apiBaseUrl}>
|
<div class="subscription-card" data-unsubscribe-root data-token={token} data-api-base={apiBaseUrl}>
|
||||||
<p class="subscription-kicker">subscriptions / unsubscribe</p>
|
<p class="subscription-kicker">subscriptions / unsubscribe</p>
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import PostCard from '../../components/PostCard.astro';
|
import PostCard from '../../components/PostCard.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
import { buildPostItemList } from '../../lib/seo';
|
||||||
import type { Post, Tag } from '../../lib/types';
|
import type { Post, Tag } from '../../lib/types';
|
||||||
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
let tags: Tag[] = [];
|
let tags: Tag[] = [];
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
let tagsFailed = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[tags, posts] = await Promise.all([apiClient.getTags(), apiClient.getPosts()]);
|
const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
|
||||||
|
apiClient.getTags(),
|
||||||
|
apiClient.getPosts(),
|
||||||
|
apiClient.getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tagsResult.status === 'fulfilled') {
|
||||||
|
tags = tagsResult.value;
|
||||||
|
} else {
|
||||||
|
tagsFailed = true;
|
||||||
|
console.error('Failed to fetch tags:', tagsResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postsResult.status === 'fulfilled') {
|
||||||
|
posts = postsResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch tag posts:', postsResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsResult.status === 'fulfilled') {
|
||||||
|
siteSettings = settingsResult.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tag detail data:', error);
|
console.error('Failed to fetch tag detail data:', error);
|
||||||
}
|
}
|
||||||
@@ -31,7 +58,10 @@ const tag =
|
|||||||
}) || null;
|
}) || null;
|
||||||
|
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, {
|
||||||
|
status: tagsFailed ? 503 : 404,
|
||||||
|
headers: tagsFailed ? { 'Retry-After': '120' } : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalUrl = buildTagUrl(tag);
|
const canonicalUrl = buildTagUrl(tag);
|
||||||
@@ -48,8 +78,24 @@ const pageDescription = tag.seoDescription || tag.description || t('tags.selecte
|
|||||||
tag: tag.name,
|
tag: tag.name,
|
||||||
count: filteredPosts.length,
|
count: filteredPosts.length,
|
||||||
});
|
});
|
||||||
const siteBaseUrl = new URL(Astro.request.url).origin;
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'tag hub',
|
||||||
|
title: 'Share this tag hub',
|
||||||
|
description:
|
||||||
|
'Use this tag archive as a compact topic cluster so people and AI retrieval systems can discover related posts from one canonical URL.',
|
||||||
|
posts: 'Posts',
|
||||||
|
tag: 'Tag',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '标签聚合',
|
||||||
|
title: '分享这个标签聚合页',
|
||||||
|
description: '把这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。',
|
||||||
|
posts: '文章数',
|
||||||
|
tag: '标签',
|
||||||
|
};
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -65,6 +111,12 @@ const jsonLd = [
|
|||||||
},
|
},
|
||||||
keywords: [tag.name, tag.slug].filter(Boolean),
|
keywords: [tag.name, tag.slug].filter(Boolean),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${tag.name} posts`,
|
||||||
|
itemListElement: buildPostItemList(filteredPosts, siteBaseUrl),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'BreadcrumbList',
|
'@type': 'BreadcrumbList',
|
||||||
@@ -100,6 +152,7 @@ const jsonLd = [
|
|||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
twitterCard={tag.coverImage ? 'summary_large_image' : 'summary'}
|
twitterCard={tag.coverImage ? 'summary_large_image' : 'summary'}
|
||||||
>
|
>
|
||||||
|
<PageViewTracker pageType="tag" entityId={tag.slug || tag.name} />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title={`~/tags/${tag.slug || tag.name}`} class="w-full">
|
<TerminalWindow title={`~/tags/${tag.slug || tag.name}`} class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
@@ -144,6 +197,21 @@ const jsonLd = [
|
|||||||
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={pageTitle}
|
||||||
|
summary={pageDescription}
|
||||||
|
canonicalUrl={absoluteCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / taxonomy"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.posts, value: String(filteredPosts.length) },
|
||||||
|
{ label: sharePanelCopy.tag, value: tag.slug || tag.name },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
{tag.coverImage ? (
|
{tag.coverImage ? (
|
||||||
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,27 +1,85 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n } from '../../lib/i18n';
|
import { getI18n } from '../../lib/i18n';
|
||||||
import type { Tag } from '../../lib/types';
|
import type { Tag } from '../../lib/types';
|
||||||
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const { t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
let tags: Tag[] = [];
|
let tags: Tag[] = [];
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tags = await apiClient.getTags();
|
const [tagsResult, settingsResult] = await Promise.allSettled([
|
||||||
|
apiClient.getTags(),
|
||||||
|
apiClient.getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tagsResult.status === 'fulfilled') {
|
||||||
|
tags = tagsResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch tags:', tagsResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsResult.status === 'fulfilled') {
|
||||||
|
siteSettings = settingsResult.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tags:', error);
|
console.error('Failed to fetch tags:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const canonicalUrl = new URL('/tags', siteBaseUrl).toString();
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'tag directory',
|
||||||
|
title: 'Share the tag directory',
|
||||||
|
description:
|
||||||
|
'Publish the tag overview as a compact topic graph so AI retrieval and readers can jump into the right subject clusters from one canonical page.',
|
||||||
|
tags: 'Tags',
|
||||||
|
site: 'Site',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '标签目录',
|
||||||
|
title: '分享标签总览页',
|
||||||
|
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。',
|
||||||
|
tags: '标签数',
|
||||||
|
site: '站点',
|
||||||
|
};
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('tags.title'),
|
||||||
|
description: t('tags.intro'),
|
||||||
|
url: canonicalUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('tags.title')} list`,
|
||||||
|
itemListElement: tags.map((tag, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
url: new URL(buildTagUrl(tag), siteBaseUrl).toString(),
|
||||||
|
name: tag.name,
|
||||||
|
description: tag.description || tag.seoDescription || `${tag.name} topic hub`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
|
<BaseLayout title={`${t('tags.pageTitle')} - Termi`} jsonLd={jsonLd}>
|
||||||
|
<PageViewTracker pageType="tags" entityId="tags-index" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/tags" class="w-full">
|
<TerminalWindow title="~/tags" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -50,6 +108,23 @@ try {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('tags.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('tags.intro')}
|
||||||
|
canonicalUrl={canonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / taxonomy"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.tags, value: String(tags.length) },
|
||||||
|
{ label: sharePanelCopy.site, value: siteSettings.siteShortName || siteSettings.siteName },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../../layouts/BaseLayout.astro';
|
import Layout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||||
|
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
import { getI18n, formatReadTime } from '../../lib/i18n';
|
import { getI18n, formatReadTime } from '../../lib/i18n';
|
||||||
|
import { buildPostItemList } from '../../lib/seo';
|
||||||
import type { Post } from '../../lib/types';
|
import type { Post } from '../../lib/types';
|
||||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme } from '../../lib/utils';
|
import { getAccentVars, getCategoryTheme, getPostTypeTheme } from '../../lib/utils';
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ export const prerender = false;
|
|||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
const isEnglish = locale.startsWith('en');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[siteSettings, posts] = await Promise.all([
|
[siteSettings, posts] = await Promise.all([
|
||||||
@@ -32,9 +36,69 @@ const groupedByYear = posts.reduce((acc: Record<number, Post[]>, post) => {
|
|||||||
|
|
||||||
const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
|
const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
|
||||||
const latestYear = years[0] || 'all';
|
const latestYear = years[0] || 'all';
|
||||||
|
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||||
|
const timelineCanonicalUrl = new URL('/timeline', siteBaseUrl).toString();
|
||||||
|
const timelineJsonLd = [
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: t('timeline.title'),
|
||||||
|
description: t('timeline.pageDescription', { ownerName: siteSettings.ownerName }),
|
||||||
|
url: timelineCanonicalUrl,
|
||||||
|
inLanguage: locale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
name: `${t('timeline.title')} list`,
|
||||||
|
itemListElement: buildPostItemList(posts.slice(0, 20), siteBaseUrl),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 1,
|
||||||
|
name: siteSettings.siteName,
|
||||||
|
item: siteBaseUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: t('timeline.title'),
|
||||||
|
item: timelineCanonicalUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const sharePanelCopy = isEnglish
|
||||||
|
? {
|
||||||
|
badge: 'activity log',
|
||||||
|
title: 'Share the timeline',
|
||||||
|
description:
|
||||||
|
'Use the timeline as the canonical chronological map of posts so AI search and readers can understand publishing cadence and topic evolution.',
|
||||||
|
posts: 'Posts',
|
||||||
|
years: 'Years',
|
||||||
|
latest: 'Latest',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
badge: '时间线',
|
||||||
|
title: '分享站点时间线',
|
||||||
|
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。',
|
||||||
|
posts: '文章数',
|
||||||
|
years: '年份数',
|
||||||
|
latest: '最近年份',
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`} description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}>
|
<Layout
|
||||||
|
title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`}
|
||||||
|
description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
jsonLd={timelineJsonLd}
|
||||||
|
>
|
||||||
|
<PageViewTracker pageType="timeline" entityId="timeline-index" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/timeline" class="w-full">
|
<TerminalWindow title="~/timeline" class="w-full">
|
||||||
<div class="px-4 py-4 space-y-6">
|
<div class="px-4 py-4 space-y-6">
|
||||||
@@ -54,6 +118,24 @@ const latestYear = years[0] || 'all';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||||
|
summary={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}
|
||||||
|
canonicalUrl={timelineCanonicalUrl}
|
||||||
|
badge={sharePanelCopy.badge}
|
||||||
|
kicker="geo / timeline"
|
||||||
|
title={sharePanelCopy.title}
|
||||||
|
description={sharePanelCopy.description}
|
||||||
|
stats={[
|
||||||
|
{ label: sharePanelCopy.posts, value: String(posts.length) },
|
||||||
|
{ label: sharePanelCopy.years, value: String(years.length) },
|
||||||
|
{ label: sharePanelCopy.latest, value: latestYear },
|
||||||
|
]}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:ui:admin": "pnpm --dir ./playwright-smoke test:admin",
|
"test:ui:admin": "pnpm --dir ./playwright-smoke test:admin",
|
||||||
"test:ui:headed": "pnpm --dir ./playwright-smoke test:headed",
|
"test:ui:headed": "pnpm --dir ./playwright-smoke test:headed",
|
||||||
"test:ui:install-browsers": "pnpm --dir ./playwright-smoke install:browsers",
|
"test:ui:install-browsers": "pnpm --dir ./playwright-smoke install:browsers",
|
||||||
|
"indexnow:submit": "pnpm --dir ./frontend run indexnow:submit",
|
||||||
"stop": "powershell -ExecutionPolicy Bypass -File ./stop-services.ps1",
|
"stop": "powershell -ExecutionPolicy Bypass -File ./stop-services.ps1",
|
||||||
"restart": "powershell -ExecutionPolicy Bypass -File ./restart-services.ps1"
|
"restart": "powershell -ExecutionPolicy Bypass -File ./restart-services.ps1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ pnpm test:ui
|
|||||||
- 本地默认优先走已安装的 `msedge` channel,CI 仍使用 `playwright install chromium`。
|
- 本地默认优先走已安装的 `msedge` channel,CI 仍使用 `playwright install chromium`。
|
||||||
- 当前已覆盖:
|
- 当前已覆盖:
|
||||||
- 前台:首页过滤、文章详情、评论、搜索、AI 问答、友链申请、订阅确认/管理/退订
|
- 前台:首页过滤、文章详情、评论、搜索、AI 问答、友链申请、订阅确认/管理/退订
|
||||||
|
- 前台 GEO 能力:llms.txt / llms-full.txt 入口、分享面板、微信扫码分享、AI 摘要块
|
||||||
- 后台:登录、导航、评论审核、友链审核
|
- 后台:登录、导航、评论审核、友链审核
|
||||||
- 后台深度回归:分类 CRUD、标签 CRUD、订阅 CRUD / 测试发送 / weekly & monthly digest、文章创建/保存/版本恢复/删除、媒体上传/元数据/替换/删除、站点设置保存/AI 重建索引/Provider 连通性/存储连通性、评测 CRUD / AI 润色、评论画像与黑名单管理
|
- 后台深度回归:分类 CRUD、标签 CRUD、订阅 CRUD / 测试发送 / weekly & monthly digest、文章创建/保存/版本恢复/删除、媒体上传/元数据/替换/删除、站点设置保存 / 微信扫码分享开关 / AI 重建索引 / Provider 连通性 / 存储连通性、评测 CRUD / AI 润色、评论画像与黑名单管理
|
||||||
|
|||||||
@@ -824,6 +824,7 @@ function createSiteSettings() {
|
|||||||
media_r2_secret_access_key: 'mock-secret',
|
media_r2_secret_access_key: 'mock-secret',
|
||||||
seo_default_og_image: `${MOCK_ORIGIN}/media-files/default-og.svg`,
|
seo_default_og_image: `${MOCK_ORIGIN}/media-files/default-og.svg`,
|
||||||
seo_default_twitter_handle: '@initcool',
|
seo_default_twitter_handle: '@initcool',
|
||||||
|
seo_wechat_share_qr_enabled: false,
|
||||||
notification_webhook_url: 'https://notify.mock.invalid/termi',
|
notification_webhook_url: 'https://notify.mock.invalid/termi',
|
||||||
notification_channel_type: 'webhook',
|
notification_channel_type: 'webhook',
|
||||||
notification_comment_enabled: true,
|
notification_comment_enabled: true,
|
||||||
@@ -2528,8 +2529,19 @@ const server = createServer(async (req, res) => {
|
|||||||
recent_events: [],
|
recent_events: [],
|
||||||
providers_last_7d: [{ provider: 'mock-openai', count: 18 }],
|
providers_last_7d: [{ provider: 'mock-openai', count: 18 }],
|
||||||
top_referrers: [{ referrer: 'homepage', count: 44 }],
|
top_referrers: [{ referrer: 'homepage', count: 44 }],
|
||||||
|
ai_referrers_last_7d: [
|
||||||
|
{ referrer: 'chatgpt-search', count: 21 },
|
||||||
|
{ referrer: 'perplexity', count: 9 },
|
||||||
|
{ referrer: 'copilot-bing', count: 6 },
|
||||||
|
],
|
||||||
|
ai_discovery_page_views_last_7d: 36,
|
||||||
popular_posts: getHomePayload().popular_posts,
|
popular_posts: getHomePayload().popular_posts,
|
||||||
daily_activity: [{ date: '2026-04-01', searches: 9, ai_questions: 5 }],
|
daily_activity: [
|
||||||
|
{ date: '2026-03-29', searches: 6, ai_questions: 3 },
|
||||||
|
{ date: '2026-03-30', searches: 7, ai_questions: 4 },
|
||||||
|
{ date: '2026-03-31', searches: 11, ai_questions: 6 },
|
||||||
|
{ date: '2026-04-01', searches: 9, ai_questions: 5 },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2707,6 +2719,7 @@ const server = createServer(async (req, res) => {
|
|||||||
mediaR2SecretAccessKey: 'media_r2_secret_access_key',
|
mediaR2SecretAccessKey: 'media_r2_secret_access_key',
|
||||||
seoDefaultOgImage: 'seo_default_og_image',
|
seoDefaultOgImage: 'seo_default_og_image',
|
||||||
seoDefaultTwitterHandle: 'seo_default_twitter_handle',
|
seoDefaultTwitterHandle: 'seo_default_twitter_handle',
|
||||||
|
seoWechatShareQrEnabled: 'seo_wechat_share_qr_enabled',
|
||||||
notificationWebhookUrl: 'notification_webhook_url',
|
notificationWebhookUrl: 'notification_webhook_url',
|
||||||
notificationChannelType: 'notification_channel_type',
|
notificationChannelType: 'notification_channel_type',
|
||||||
notificationCommentEnabled: 'notification_comment_enabled',
|
notificationCommentEnabled: 'notification_comment_enabled',
|
||||||
|
|||||||
@@ -315,6 +315,10 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
|
|||||||
await page.getByRole('link', { name: '设置' }).click()
|
await page.getByRole('link', { name: '设置' }).click()
|
||||||
await page.getByTestId('site-settings-site-name').fill('InitCool Deep Regression')
|
await page.getByTestId('site-settings-site-name').fill('InitCool Deep Regression')
|
||||||
await page.getByTestId('site-settings-popup-title').fill('订阅深回归')
|
await page.getByTestId('site-settings-popup-title').fill('订阅深回归')
|
||||||
|
await page
|
||||||
|
.locator('label', { hasText: '开启文章页微信扫码分享' })
|
||||||
|
.locator('input[type="checkbox"]')
|
||||||
|
.check()
|
||||||
await page.getByTestId('site-settings-save').click()
|
await page.getByTestId('site-settings-save').click()
|
||||||
await page.getByTestId('site-settings-reindex').click()
|
await page.getByTestId('site-settings-reindex').click()
|
||||||
await page.getByTestId('site-settings-test-provider').click()
|
await page.getByTestId('site-settings-test-provider').click()
|
||||||
@@ -324,6 +328,7 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
|
|||||||
state = await getDebugState(request)
|
state = await getDebugState(request)
|
||||||
expect(state.site_settings.site_name).toBe('InitCool Deep Regression')
|
expect(state.site_settings.site_name).toBe('InitCool Deep Regression')
|
||||||
expect(state.site_settings.subscription_popup_title).toBe('订阅深回归')
|
expect(state.site_settings.subscription_popup_title).toBe('订阅深回归')
|
||||||
|
expect(state.site_settings.seo_wechat_share_qr_enabled).toBe(true)
|
||||||
expect(state.site_settings.ai_chunks_count).toBeGreaterThan(128)
|
expect(state.site_settings.ai_chunks_count).toBeGreaterThan(128)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
import { getDebugState, resetMockState } from './helpers'
|
import { getDebugState, patchAdminSiteSettings, resetMockState } from './helpers'
|
||||||
|
|
||||||
test.beforeEach(async ({ request }) => {
|
test.beforeEach(async ({ request }) => {
|
||||||
await resetMockState(request)
|
await resetMockState(request)
|
||||||
@@ -100,6 +100,7 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
|
|||||||
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
|
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
|
||||||
|
|
||||||
await page.locator('[data-subscription-popup-open]').click()
|
await page.locator('[data-subscription-popup-open]').click()
|
||||||
|
await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible()
|
||||||
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
|
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
|
||||||
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
|
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
|
||||||
await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
|
await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
|
||||||
@@ -128,3 +129,49 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
|
|||||||
await page.getByRole('button', { name: '确认退订' }).click()
|
await page.getByRole('button', { name: '确认退订' }).click()
|
||||||
await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订')
|
await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GEO 分享面板、AI 摘要块与 llms 入口可用', async ({ page, request }) => {
|
||||||
|
await patchAdminSiteSettings(request, {
|
||||||
|
seoWechatShareQrEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1)
|
||||||
|
await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1)
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的站点摘要' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '微信扫码' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/about')
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的身份摘要' })).toBeVisible()
|
||||||
|
await expect(page.getByText('身份主页')).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/articles')
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的归档摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/reviews')
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的评测摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/ask')
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的问答页摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/friends')
|
||||||
|
await expect(page.getByRole('heading', { name: '给 AI 看的友链网络摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/articles/playwright-regression-workflow')
|
||||||
|
await page.getByRole('button', { name: '微信扫码' }).first().click()
|
||||||
|
await expect(page.locator('[data-article-wechat-qr-modal]')).toHaveAttribute('aria-hidden', 'false')
|
||||||
|
await expect(page.getByRole('heading', { name: '微信扫码分享' })).toBeVisible()
|
||||||
|
await expect(page.locator('[data-article-qr-download]')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('[data-article-wechat-qr-close]').first().click()
|
||||||
|
await expect(page.locator('[data-article-wechat-qr-modal]')).toHaveAttribute('aria-hidden', 'true')
|
||||||
|
|
||||||
|
await page.goto('/categories/frontend-engineering')
|
||||||
|
await expect(page.getByRole('button', { name: '复制摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/tags/playwright')
|
||||||
|
await expect(page.getByRole('button', { name: '分享摘要' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto('/reviews/1')
|
||||||
|
await expect(page.getByRole('button', { name: '复制摘要' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export async function getDebugState(request: APIRequestContext) {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchAdminSiteSettings(
|
||||||
|
request: APIRequestContext,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const response = await request.patch(`${MOCK_BASE_URL}/api/admin/site-settings`, {
|
||||||
|
headers: {
|
||||||
|
cookie: `${ADMIN_COOKIE.name}=${ADMIN_COOKIE.value}`,
|
||||||
|
},
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginAdmin(page: Page) {
|
export async function loginAdmin(page: Page) {
|
||||||
await page.goto('/login')
|
await page.goto('/login')
|
||||||
await page.getByLabel('用户名').fill('admin')
|
await page.getByLabel('用户名').fill('admin')
|
||||||
|
|||||||
Reference in New Issue
Block a user