11 Commits

Author SHA1 Message Date
7d4f027062 Update frontend test favicon
All checks were successful
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / build-and-push (admin) (push) Successful in 2s
docker-images / build-and-push (backend) (push) Successful in 2s
docker-images / build-and-push (frontend) (push) Successful in 56s
docker-images / submit-indexnow (push) Successful in 15s
2026-04-05 17:07:55 +08:00
646a32f207 Ignore local artifacts and wrap worker job text
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 35s
docker-images / build-and-push (backend) (push) Successful in 2s
docker-images / build-and-push (frontend) (push) Successful in 2s
docker-images / submit-indexnow (push) Has been skipped
Ignore local artifacts and wrap worker job text
2026-04-03 20:26:36 +00:00
381dc9b854 Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
2026-04-04 04:15:20 +08:00
ab18bbaf23 Format backend controller responses
All checks were successful
docker-images / resolve-build-targets (push) Successful in 4s
docker-images / build-and-push (admin) (push) Successful in 3s
docker-images / build-and-push (backend) (push) Successful in 14m20s
docker-images / build-and-push (frontend) (push) Successful in 7s
docker-images / submit-indexnow (push) Has been skipped
2026-04-04 00:45:47 +08:00
d065e3da88 Show AI reindex progress in admin
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Failing after 8m14s
2026-04-04 00:42:23 +08:00
11ec00281c Fix AI reindex job execution and progress
Some checks failed
docker-images / resolve-build-targets (push) Failing after 1s
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Has been cancelled
2026-04-04 00:40:46 +08:00
320595ee1c Unify homepage panels and subscription actions
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
ui-regression / playwright-regression (push) Failing after 11m59s
docker-images / build-and-push (admin) (push) Successful in 3s
docker-images / build-and-push (backend) (push) Successful in 3s
docker-images / build-and-push (frontend) (push) Successful in 58s
docker-images / submit-indexnow (push) Successful in 18s
2026-04-04 00:05:38 +08:00
ad44dde886 Refine frontend navigation, loading UI, and site copy
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m3s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
2026-04-03 23:43:30 +08:00
99a57738e0 feat: 更新后端工作者内存限制为 1g,以优化性能和稳定性
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 12s
docker-images / build-and-push (backend) (push) Successful in 27m48s
docker-images / build-and-push (frontend) (push) Successful in 15s
docker-images / submit-indexnow (push) Successful in 11s
2026-04-03 15:55:26 +08:00
cf00dc5e8e feat: 添加 AI 索引重建功能,优化相关 API 和工作流,增强内存管理配置
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m43s
docker-images / build-and-push (admin) (push) Successful in 42s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has started running
2026-04-03 15:48:33 +08:00
1df179c327 Refactor SEO and JSON-LD handling; improve layout and styles
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
ui-regression / playwright-regression (push) Successful in 3m51s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / build-and-push (backend) (push) Successful in 3s
docker-images / build-and-push (frontend) (push) Successful in 1m10s
docker-images / submit-indexnow (push) Successful in 19s
- Introduced `compactJsonLd` utility to filter out falsy values from JSON-LD arrays.
- Updated various pages to utilize `compactJsonLd` for cleaner JSON-LD handling.
- Refactored music playlist configuration in Header component.
- Enhanced BaseLayout with inline script for JSON-LD and removed unnecessary media attributes from stylesheets.
- Improved error handling in category and tag pages by simplifying response logic.
- Added new styles for home hero section and sidebar components to enhance UI.
- Adjusted layout components for better responsiveness and visual consistency.
2026-04-03 13:46:08 +08:00
58 changed files with 6796 additions and 3089 deletions

View File

@@ -1,21 +1,6 @@
name: ui-regression
on:
push:
branches:
- main
- master
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
pull_request:
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
workflow_dispatch:
jobs:
@@ -35,6 +20,11 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: |
frontend/pnpm-lock.yaml
admin/pnpm-lock.yaml
playwright-smoke/pnpm-lock.yaml
- name: Install frontend deps
working-directory: frontend
@@ -48,7 +38,15 @@ jobs:
working-directory: playwright-smoke
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium
@@ -69,71 +67,22 @@ jobs:
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
run: pnpm build
- name: Prepare Playwright artifact folders
run: |
rm -rf playwright-smoke/.artifacts
mkdir -p playwright-smoke/.artifacts/frontend
mkdir -p playwright-smoke/.artifacts/admin
- name: Run frontend UI regression suite
id: ui_frontend
working-directory: playwright-smoke
continue-on-error: true
env:
PLAYWRIGHT_USE_BUILT_APP: '1'
PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:frontend
- name: Collect frontend Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
fi
rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
- name: Run admin UI regression suite
id: ui_admin
working-directory: playwright-smoke
continue-on-error: true
env:
PLAYWRIGHT_USE_BUILT_APP: '1'
PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:admin
- name: Collect admin Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
fi
- name: Summarize Playwright artifact paths
if: always()
shell: bash
run: |
set -euo pipefail
echo "Gitea Actions 当前不支持 actions/upload-artifact@v4改为直接输出产物目录"
for path in \
"playwright-smoke/.artifacts/frontend/playwright-report" \
"playwright-smoke/.artifacts/frontend/test-results" \
"playwright-smoke/.artifacts/admin/playwright-report" \
"playwright-smoke/.artifacts/admin/test-results"
do
if [ -d "${path}" ]; then
echo "- ${path}"
find "${path}" -maxdepth 2 -type f | sort | head -n 20
else
echo "- ${path} (missing)"
fi
done
- name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.codex/
.codex-tmp/
.playwright-mcp/
.vscode/
.windsurf/

View File

@@ -89,9 +89,11 @@ pnpm dev
```powershell
cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
cargo loco start 2>&1
cargo loco start --server-and-worker 2>&1
```
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`
### Docker生产部署使用 Gitea Package 镜像)
补充部署分层与反代说明见:

View File

@@ -1,7 +1,6 @@
import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
@@ -362,7 +361,7 @@ export const adminApi = {
body: JSON.stringify(payload),
}),
reindexAi: () =>
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
request<WorkerTaskActionResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
testAiProvider: (provider: {

View File

@@ -545,11 +545,6 @@ export interface TaxonomyPayload {
seoDescription?: string | null
}
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
}
export interface AdminAiProviderTestResponse {
provider: string
endpoint: string

View File

@@ -0,0 +1,127 @@
import type { WorkerJobRecord } from "@/lib/types";
type WorkerProgressShape = {
phase?: string;
message?: string;
total_chunks?: number;
processed_chunks?: number;
total_batches?: number;
current_batch?: number;
batch_size?: number;
percent?: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function getWorkerProgress(
job: Pick<WorkerJobRecord, "result">,
): WorkerProgressShape | null {
const result = asRecord(job.result);
if (!result) {
return null;
}
const nested = asRecord(result.progress);
const source = nested ?? result;
const percent = asNumber(source.percent);
const totalChunks = asNumber(source.total_chunks);
const processedChunks = asNumber(source.processed_chunks);
const totalBatches = asNumber(source.total_batches);
const currentBatch = asNumber(source.current_batch);
const batchSize = asNumber(source.batch_size);
const phase = asText(source.phase) ?? asText(result.phase) ?? undefined;
const message = asText(source.message) ?? asText(result.message) ?? undefined;
if (
percent === null &&
totalChunks === null &&
processedChunks === null &&
totalBatches === null &&
currentBatch === null &&
batchSize === null &&
!phase &&
!message
) {
return null;
}
return {
phase,
message,
total_chunks: totalChunks ?? undefined,
processed_chunks: processedChunks ?? undefined,
total_batches: totalBatches ?? undefined,
current_batch: currentBatch ?? undefined,
batch_size: batchSize ?? undefined,
percent: percent ?? undefined,
};
}
export function formatWorkerProgress(
job: Pick<WorkerJobRecord, "result">,
): string | null {
const progress = getWorkerProgress(job);
if (!progress) {
return null;
}
const percentText =
typeof progress.percent === "number"
? `${Math.max(0, Math.min(100, Math.round(progress.percent)))}%`
: null;
const chunkText =
typeof progress.processed_chunks === "number" &&
typeof progress.total_chunks === "number"
? `${progress.processed_chunks}/${progress.total_chunks} 分块`
: null;
const batchText =
typeof progress.current_batch === "number" &&
typeof progress.total_batches === "number" &&
progress.total_batches > 0
? `${progress.current_batch}/${progress.total_batches}`
: null;
const details = [percentText, chunkText, batchText]
.filter(Boolean)
.join(" · ");
if (progress.message && details) {
return `${progress.message} ${details}`;
}
return progress.message ?? (details || null);
}
export function getWorkerProgressPercent(
job: Pick<WorkerJobRecord, "result">,
): number | null {
const progress = getWorkerProgress(job);
if (typeof progress?.percent !== "number") {
return null;
}
return Math.max(0, Math.min(100, Math.round(progress.percent)));
}

View File

@@ -10,15 +10,22 @@ import {
Star,
Tags,
Workflow,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
} from "lucide-react";
import { startTransition, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/admin-format";
import {
Table,
TableBody,
@@ -26,9 +33,9 @@ import {
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
} from "@/components/ui/table";
import { adminApi, ApiError } from "@/lib/api";
import { buildFrontendUrl } from "@/lib/frontend-url";
import {
formatCommentScope,
formatPostStatus,
@@ -37,8 +44,12 @@ import {
formatPostVisibility,
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
} from "@/lib/admin-format";
import type {
AdminAnalyticsResponse,
AdminDashboardResponse,
WorkerOverview,
} from "@/lib/types";
function StatCard({
label,
@@ -46,17 +57,21 @@ function StatCard({
note,
icon: Icon,
}: {
label: string
value: number
note: string
icon: typeof Rss
label: string;
value: number;
note: string;
icon: typeof Rss;
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{label}
</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">
{value}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
@@ -64,75 +79,81 @@ function StatCard({
</div>
</CardContent>
</Card>
)
);
}
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'
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
return value;
}
}
export function DashboardPage() {
const [data, setData] = useState<AdminDashboardResponse | null>(null)
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [data, setData] = useState<AdminDashboardResponse | null>(null);
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
null,
);
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadDashboard = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
setRefreshing(true);
}
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(),
adminApi.getWorkersOverview(),
adminApi.analytics(),
])
]);
startTransition(() => {
setData(next)
setWorkerOverview(nextWorkerOverview)
setAnalytics(nextAnalytics)
})
setData(next);
setWorkerOverview(nextWorkerOverview);
setAnalytics(nextAnalytics);
});
if (showToast) {
toast.success('仪表盘已刷新。')
toast.success("仪表盘已刷新。");
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
return;
}
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
toast.error(
error instanceof ApiError ? error.message : "无法加载仪表盘。",
);
} finally {
setLoading(false)
setRefreshing(false)
setLoading(false);
setRefreshing(false);
}
}, [])
}, []);
useEffect(() => {
void loadDashboard(false)
}, [loadDashboard])
void loadDashboard(false);
}, [loadDashboard]);
if (loading || !data || !workerOverview || !analytics) {
return (
@@ -147,24 +168,24 @@ export function DashboardPage() {
<Skeleton className="h-[420px] rounded-3xl" />
</div>
</div>
)
);
}
const statCards = [
{
label: '文章总数',
label: "文章总数",
value: data.stats.total_posts,
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss,
},
{
label: '待审核评论',
label: "待审核评论",
value: data.stats.pending_comments,
note: '等待审核处理',
note: "等待审核处理",
icon: MessageSquareWarning,
},
{
label: '发布待办',
label: "发布待办",
value:
data.stats.draft_posts +
data.stats.scheduled_posts +
@@ -174,30 +195,32 @@ export function DashboardPage() {
icon: Clock3,
},
{
label: '分类数量',
label: "分类数量",
value: data.stats.total_categories,
note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree,
},
{
label: 'AI 分块',
label: "AI 分块",
value: data.stats.ai_chunks,
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
icon: BrainCircuit,
},
{
label: 'Worker 活动',
label: "Worker 活动",
value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
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
? (analytics.ai_discovery_page_views_last_7d /
analytics.content_overview.page_views_last_7d) *
100
: 0;
const topAiSource = analytics.ai_referrers_last_7d[0];
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
return (
<div className="space-y-6">
@@ -207,14 +230,15 @@ export function DashboardPage() {
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI
AI
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" />
AI
</a>
@@ -225,7 +249,7 @@ export function DashboardPage() {
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
{refreshing ? "刷新中..." : "刷新"}
</Button>
</div>
</div>
@@ -241,9 +265,7 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="outline">{data.recent_posts.length} </Badge>
</CardHeader>
@@ -265,9 +287,13 @@ export function DashboardPage() {
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title}</span>
{post.pinned ? <Badge variant="success"></Badge> : null}
{post.pinned ? (
<Badge variant="success"></Badge>
) : null}
</div>
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
<p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div>
</TableCell>
<TableCell className="uppercase text-muted-foreground">
@@ -275,12 +301,18 @@ export function DashboardPage() {
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
<Badge variant="outline">
{formatPostStatus(post.status)}
</Badge>
<Badge variant="secondary">
{formatPostVisibility(post.visibility)}
</Badge>
</div>
</TableCell>
<TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
<TableCell className="text-muted-foreground">
{post.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -291,19 +323,19 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{data.site.site_name}</p>
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
<p className="mt-1 text-sm text-muted-foreground">
{data.site.site_url}
</p>
</div>
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
<Badge variant={data.site.ai_enabled ? "success" : "warning"}>
{data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
</Badge>
</div>
</div>
@@ -314,7 +346,9 @@ export function DashboardPage() {
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
<span className="text-3xl font-semibold">
{data.stats.total_reviews}
</span>
<Star className="mb-1 h-4 w-4 text-amber-500" />
</div>
</div>
@@ -323,7 +357,9 @@ export function DashboardPage() {
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
<span className="text-3xl font-semibold">
{data.stats.total_links}
</span>
<Tags className="mb-1 h-4 w-4 text-primary" />
</div>
</div>
@@ -335,25 +371,35 @@ export function DashboardPage() {
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.draft_posts}
</p>
<p className="text-xs text-muted-foreground">稿</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.scheduled_posts}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.offline_posts}
</p>
<p className="text-xs text-muted-foreground">线</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.expired_posts}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline"> {data.stats.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge>
<Badge variant="outline">
{data.stats.unlisted_posts}
</Badge>
</div>
</div>
@@ -362,7 +408,9 @@ export function DashboardPage() {
AI
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
{data.site.ai_last_indexed_at
? formatDateTime(data.site.ai_last_indexed_at)
: "站点还没有建立过索引。"}
</p>
</div>
@@ -372,9 +420,13 @@ export function DashboardPage() {
<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-3 text-3xl font-semibold">
{analytics.ai_discovery_page_views_last_7d}
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
7 ChatGPT SearchPerplexityCopilot/BingGeminiClaude 访
7 ChatGPT
SearchPerplexityCopilot/BingGeminiClaude
访
</p>
</div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
@@ -384,23 +436,41 @@ export function DashboardPage() {
<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 className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
访
</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 className="mt-2 text-2xl font-semibold">
{Math.round(aiTrafficShare)}%
</div>
<div className="mt-1 text-xs text-muted-foreground">
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
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-2xl font-semibold">{totalAiSourceBuckets}</div>
<div className="mt-1 text-xs text-muted-foreground"> AI </div>
<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>
@@ -408,15 +478,24 @@ export function DashboardPage() {
<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,
(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>
<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
@@ -425,11 +504,13 @@ export function DashboardPage() {
/>
</div>
</div>
)
);
})}
</div>
) : (
<p className="mt-4 text-sm text-muted-foreground"> 7 AI </p>
<p className="mt-4 text-sm text-muted-foreground">
7 AI
</p>
)}
</div>
@@ -440,12 +521,17 @@ export function DashboardPage() {
Worker
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{workerOverview.queued} {workerOverview.running} {workerOverview.failed}
{workerOverview.queued}{" "}
{workerOverview.running} {workerOverview.failed}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
to={
workerOverview.failed > 0
? "/workers?status=failed"
: "/workers"
}
data-testid="dashboard-worker-open"
>
@@ -459,24 +545,36 @@ export function DashboardPage() {
data-testid="dashboard-worker-card-queued"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Queued
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.queued}
</div>
</Link>
<Link
to="/workers?status=running"
data-testid="dashboard-worker-card-running"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Running
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.running}
</div>
</Link>
<Link
to="/workers?status=failed"
data-testid="dashboard-worker-card-failed"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Failed
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.failed}
</div>
</Link>
</div>
@@ -489,11 +587,17 @@ export function DashboardPage() {
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div>
<div className="font-medium text-foreground">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
<div className="font-medium text-foreground">
{item.label}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.worker_name}
</div>
</div>
<div className="text-right text-xs text-muted-foreground">
<div>Q {item.queued} · R {item.running}</div>
<div>
Q {item.queued} · R {item.running}
</div>
<div>ERR {item.failed}</div>
</div>
</Link>
@@ -510,11 +614,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="warning">{data.pending_comments.length} </Badge>
<Badge variant="warning">
{data.pending_comments.length}
</Badge>
</CardHeader>
<CardContent>
<Table>
@@ -543,7 +647,9 @@ export function DashboardPage() {
<TableCell className="font-mono text-xs text-muted-foreground">
{comment.post_slug}
</TableCell>
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
<TableCell className="text-muted-foreground">
{comment.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -556,11 +662,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="warning">{data.pending_friend_links.length} </Badge>
<Badge variant="warning">
{data.pending_friend_links.length}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.pending_friend_links.map((link) => (
@@ -591,9 +697,7 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.recent_reviews.map((review) => (
@@ -604,11 +708,14 @@ export function DashboardPage() {
<div className="min-w-0">
<p className="font-medium">{review.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
{formatReviewType(review.review_type)} ·{" "}
{formatReviewStatus(review.status)}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold">{review.rating}/5</div>
<div className="text-lg font-semibold">
{review.rating}/5
</div>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{review.review_date}
</p>
@@ -620,5 +727,5 @@ export function DashboardPage() {
</div>
</div>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,15 @@ Loco.rs backend当前仅保留 API 与后台鉴权相关逻辑,不再提供
## 本地启动
```powershell
cargo loco start
cargo loco start --server-and-worker
```
默认本地监听:
- `http://localhost:5150`
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
## 当前职责
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API

View File

@@ -3,12 +3,12 @@
site_short_name: "Termi"
site_url: "https://init.cool"
site_title: "InitCool · 技术笔记与内容档案"
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
hero_title: "欢迎来到 InitCool"
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
owner_title: "负责把脑洞拧成页面的人"
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool"
social_twitter: ""

View File

@@ -28,7 +28,10 @@ use crate::{
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
},
tasks,
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
workers::{
ai_reindex::AiReindexWorker, downloader::DownloadWorker,
notification_delivery::NotificationDeliveryWorker,
},
};
pub struct App;
@@ -153,6 +156,7 @@ impl Hooks for App {
Ok(router.layer(cors))
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(AiReindexWorker::build(ctx)).await?;
queue.register(DownloadWorker::build(ctx)).await?;
queue
.register(NotificationDeliveryWorker::build(ctx))

View File

@@ -230,8 +230,8 @@ pub struct AdminSiteSettingsResponse {
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
#[derive(Clone, Debug, Deserialize)]
@@ -1395,16 +1395,28 @@ pub async fn update_site_settings(
#[debug_handler]
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let summary = ai::rebuild_index(&ctx).await?;
let actor = check_auth(&headers)?;
let job = worker_jobs::queue_ai_reindex_job(
&ctx,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
format::json(AdminAiReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(
summary.last_indexed_at.map(Into::into),
"%Y-%m-%d %H:%M:%S UTC",
),
})
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.ai_reindex",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
None,
)
.await?;
format::json(AdminAiReindexResponse { queued: true, job })
}
#[debug_handler]

View File

@@ -16,7 +16,7 @@ use std::time::Instant;
use crate::{
controllers::{admin::check_auth, site_settings},
services::{abuse_guard, ai, analytics},
services::{abuse_guard, ai, analytics, worker_jobs},
};
#[derive(Clone, Debug, Deserialize)]
@@ -35,8 +35,8 @@ pub struct AskResponse {
#[derive(Clone, Debug, Serialize)]
pub struct ReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
#[derive(Clone, Debug, Serialize)]
@@ -514,13 +514,17 @@ pub async fn ask_stream(
#[debug_handler]
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let summary = ai::rebuild_index(&ctx).await?;
let actor = check_auth(&headers)?;
let job = worker_jobs::queue_ai_reindex_job(
&ctx,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
format::json(ReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(summary.last_indexed_at),
})
format::json(ReindexResponse { queued: true, job })
}
pub fn routes() -> Routes {

View File

@@ -4,9 +4,9 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sha2::{Digest, Sha256};
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use uuid::Uuid;

View File

@@ -3,12 +3,12 @@
site_short_name: "Termi"
site_url: "https://init.cool"
site_title: "InitCool · 技术笔记与内容档案"
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
hero_title: "欢迎来到 InitCool"
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
owner_title: "负责把脑洞拧成页面的人"
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool"
social_twitter: ""

View File

@@ -7,7 +7,7 @@ use loco_rs::prelude::*;
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
use sea_orm::{
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
PaginatorTrait, QueryOrder, Set, Statement,
PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -19,7 +19,7 @@ use uuid::Uuid;
use crate::{
controllers::site_settings as site_settings_controller,
models::_entities::{ai_chunks, site_settings},
services::{content, storage},
services::{content, storage, worker_jobs},
};
const DEFAULT_AI_PROVIDER: &str = "openai";
@@ -36,6 +36,7 @@ const DEFAULT_TOP_K: usize = 4;
const DEFAULT_CHUNK_SIZE: usize = 1200;
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
const EMBEDDING_BATCH_SIZE: usize = 32;
pub(crate) const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
const EMBEDDING_DIMENSION: usize = 384;
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
@@ -202,6 +203,18 @@ pub struct AiIndexSummary {
pub last_indexed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize)]
struct AiReindexProgress {
phase: String,
message: String,
total_chunks: usize,
processed_chunks: usize,
total_batches: usize,
current_batch: usize,
batch_size: usize,
percent: usize,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
@@ -771,6 +784,14 @@ pub fn default_image_model_for_provider(provider: &str) -> &'static str {
}
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
embed_texts_locally_with_batch_size(inputs, kind, EMBEDDING_BATCH_SIZE).await
}
async fn embed_texts_locally_with_batch_size(
inputs: Vec<String>,
kind: EmbeddingKind,
batch_size: usize,
) -> Result<Vec<Vec<f64>>> {
tokio::task::spawn_blocking(move || {
let model = local_embedding_engine()?;
let prepared = inputs
@@ -783,7 +804,7 @@ async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result
})?;
let embeddings = guard
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
.embed(prepared, Some(batch_size.max(1)))
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
Ok(embeddings
@@ -2461,6 +2482,73 @@ fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
)
}
fn build_reindex_progress(
phase: &str,
message: String,
total_chunks: usize,
processed_chunks: usize,
batch_size: usize,
) -> AiReindexProgress {
let normalized_batch_size = batch_size.max(1);
let total_batches = total_chunks.div_ceil(normalized_batch_size);
let current_batch = if processed_chunks == 0 {
0
} else {
processed_chunks
.div_ceil(normalized_batch_size)
.min(total_batches)
};
let percent = if total_chunks == 0 {
100
} else {
((processed_chunks * 100) / total_chunks).min(100)
};
AiReindexProgress {
phase: phase.to_string(),
message,
total_chunks,
processed_chunks,
total_batches,
current_batch,
batch_size: normalized_batch_size,
percent,
}
}
async fn update_reindex_job_progress(
ctx: &AppContext,
job_id: Option<i32>,
progress: &AiReindexProgress,
) -> Result<()> {
let Some(job_id) = job_id else {
return Ok(());
};
worker_jobs::update_job_result(
ctx,
job_id,
serde_json::json!({
"phase": progress.phase,
"message": progress.message,
"progress": progress,
}),
)
.await
}
async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
let Some(job_id) = job_id else {
return Ok(());
};
if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
return Err(Error::BadRequest("job cancelled".to_string()));
}
Ok(())
}
async fn load_runtime_settings(
ctx: &AppContext,
require_enabled: bool,
@@ -2555,14 +2643,14 @@ async fn load_runtime_settings(
})
}
async fn update_indexed_at(
ctx: &AppContext,
async fn update_indexed_at<C: ConnectionTrait>(
db: &C,
settings: &site_settings::Model,
) -> Result<DateTime<Utc>> {
let now = Utc::now();
let mut model = settings.clone().into_active_model();
model.ai_last_indexed_at = Set(Some(now.into()));
let _ = model.update(&ctx.db).await?;
let _ = model.update(db).await?;
Ok(now)
}
@@ -2571,14 +2659,8 @@ async fn retrieve_matches(
settings: &AiRuntimeSettings,
question: &str,
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
if indexed_chunks == 0 {
let summary = rebuild_index(ctx).await?;
indexed_chunks = summary.indexed_chunks;
last_indexed_at = summary.last_indexed_at;
}
let indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
let last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
if indexed_chunks == 0 {
return Ok((Vec::new(), 0, last_indexed_at));
@@ -2640,32 +2722,49 @@ async fn retrieve_matches(
Ok((matches, indexed_chunks, last_indexed_at))
}
pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIndexSummary> {
let settings = load_runtime_settings(ctx, false).await?;
let posts = content::load_markdown_posts_from_store(ctx).await?;
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
let embeddings = if chunk_drafts.is_empty() {
Vec::new()
let total_chunks = chunk_drafts.len();
let batch_size = REINDEX_EMBEDDING_BATCH_SIZE.max(1);
let preparing_progress = build_reindex_progress(
"preparing",
if total_chunks == 0 {
"没有可写入的内容,正在清理旧索引。".to_string()
} else {
embed_texts_locally(
chunk_drafts
.iter()
.map(|chunk| chunk.content.clone())
.collect::<Vec<_>>(),
EmbeddingKind::Passage,
)
.await?
};
format!("已收集 {total_chunks} 个分块,准备重建向量索引。")
},
total_chunks,
0,
batch_size,
);
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let txn = ctx.db.begin().await?;
ctx.db
.execute(Statement::from_string(
txn.execute(Statement::from_string(
DbBackend::Postgres,
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
))
.await?;
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
let mut processed_chunks = 0usize;
for chunk_batch in chunk_drafts.chunks(batch_size) {
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let embeddings = embed_texts_locally_with_batch_size(
chunk_batch
.iter()
.map(|chunk| chunk.content.clone())
.collect::<Vec<_>>(),
EmbeddingKind::Passage,
batch_size,
)
.await?;
for (draft, embedding) in chunk_batch.iter().zip(embeddings.into_iter()) {
let embedding_literal = vector_literal(&embedding)?;
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
@@ -2696,10 +2795,27 @@ pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
draft.word_count.into(),
],
);
ctx.db.execute(statement).await?;
txn.execute(statement).await?;
}
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
processed_chunks += chunk_batch.len();
let embedding_progress = build_reindex_progress(
"embedding",
format!(
"正在写入第 {}/{} 批向量。",
processed_chunks.div_ceil(batch_size),
total_chunks.div_ceil(batch_size)
),
total_chunks,
processed_chunks,
batch_size,
);
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
}
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
txn.commit().await?;
Ok(AiIndexSummary {
indexed_chunks: chunk_drafts.len(),

View File

@@ -40,7 +40,12 @@ pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending";
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
const MAX_DELIVERY_ATTEMPTS: i32 = 5;
const WEB_PUSH_TITLE_MAX_CHARS: usize = 72;
const WEB_PUSH_BODY_MAX_CHARS: usize = 160;
const WEB_PUSH_MAX_PAYLOAD_BYTES: usize = 2800;
const WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD: i32 = 2;
const WEB_PUSH_AUTO_PAUSE_NOTE: &str =
"浏览器推送订阅连续投递失败,系统已自动暂停。请在浏览器里重新开启提醒。";
#[derive(Clone, Debug, Serialize)]
pub struct DigestDispatchSummary {
@@ -259,6 +264,97 @@ fn merge_browser_push_metadata(
Value::Object(object)
}
fn merge_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let mut lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>();
if !note.is_empty() && !lines.iter().any(|line| line == note) {
lines.push(note.to_string());
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn remove_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && *line != note)
.map(ToString::to_string)
.collect::<Vec<_>>();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn web_push_error_looks_terminal(error_text: &str) -> bool {
let normalized = error_text.trim().to_ascii_lowercase();
normalized.contains("endpoint_host=fcm.googleapis.com")
&& normalized.contains("unspecified error")
|| normalized.contains("410")
|| normalized.contains("404")
|| normalized.contains("gone")
|| normalized.contains("not found")
|| normalized.contains("expired")
|| normalized.contains("unsubscribed")
|| normalized.contains("invalid subscription")
|| normalized.contains("push subscription")
}
fn should_auto_pause_failed_web_push_subscription(
failure_count_after_error: i32,
error_text: &str,
) -> bool {
failure_count_after_error >= WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD
|| web_push_error_looks_terminal(error_text)
}
async fn maybe_pause_failed_web_push_subscription(
ctx: &AppContext,
subscription: Option<&subscriptions::Model>,
error_text: &str,
) -> Result<()> {
let Some(subscription) = subscription else {
return Ok(());
};
if subscription.channel_type != CHANNEL_WEB_PUSH
|| normalize_status(&subscription.status) != STATUS_ACTIVE
{
return Ok(());
}
let failure_count_after_error = subscription.failure_count.unwrap_or(0) + 1;
if !should_auto_pause_failed_web_push_subscription(failure_count_after_error, error_text) {
return Ok(());
}
let mut active = subscription.clone().into_active_model();
active.status = Set(STATUS_PAUSED.to_string());
active.notes = Set(merge_subscription_note(
subscription.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
let _ = active.update(&ctx.db).await?;
Ok(())
}
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
value
.and_then(Value::as_object)
@@ -321,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
values
}
fn delivery_retry_delay(attempts: i32) -> Duration {
match attempts {
0 | 1 => Duration::minutes(1),
2 => Duration::minutes(5),
3 => Duration::minutes(15),
4 => Duration::minutes(60),
_ => Duration::hours(6),
}
}
fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
match period.trim().to_ascii_lowercase().as_str() {
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
@@ -680,6 +766,12 @@ pub async fn create_public_web_push_subscription(
active.status = Set(STATUS_ACTIVE.to_string());
active.confirm_token = Set(None);
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
active.failure_count = Set(Some(0));
active.last_delivery_status = Set(None);
active.notes = Set(remove_subscription_note(
existing.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
active.metadata = Set(Some(merge_browser_push_metadata(
existing.metadata.as_ref(),
metadata,
@@ -1066,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
}
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
let body = truncate_chars(&collapse_whitespace(&message.text), 220);
serde_json::json!({
"title": message.subject,
"body": body,
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
"url": web_push_target_url(message),
"tag": message
let title = truncate_chars(
&collapse_whitespace(&message.subject),
WEB_PUSH_TITLE_MAX_CHARS,
);
let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
let url = web_push_target_url(message);
let event_type = message
.payload
.get("event_type")
.and_then(Value::as_str)
.unwrap_or("subscription"),
.unwrap_or("subscription");
serde_json::json!({
"title": title,
"body": body,
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
"url": url.clone(),
"tag": event_type,
"data": {
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null),
"payload": message.payload,
"url": url,
"event_type": event_type,
}
})
}
fn encode_web_push_payload(message: &QueuedDeliveryPayload) -> Result<Vec<u8>> {
let payload = build_web_push_payload(message);
let encoded = serde_json::to_vec(&payload)?;
if encoded.len() > WEB_PUSH_MAX_PAYLOAD_BYTES {
return Err(Error::BadRequest(format!(
"web push payload too large: {} bytes exceeds safe limit {} bytes",
encoded.len(),
WEB_PUSH_MAX_PAYLOAD_BYTES
)));
}
Ok(encoded)
}
async fn deliver_via_channel(
ctx: &AppContext,
channel_type: &str,
@@ -1126,7 +1239,7 @@ async fn deliver_via_channel(
CHANNEL_WEB_PUSH => {
let settings = crate::controllers::site_settings::load_current(ctx).await?;
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
let payload = serde_json::to_vec(&build_web_push_payload(message))?;
let payload = encode_web_push_payload(message)?;
web_push_service::send_payload(
&settings,
&subscription_info,
@@ -1275,24 +1388,25 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.await?;
}
Err(error) => {
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS)
.then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339());
let status = if next_retry_at.is_some() {
DELIVERY_STATUS_RETRY_PENDING
} else {
DELIVERY_STATUS_EXHAUSTED
};
let error_text = error.to_string();
let mut active = delivery.into_active_model();
active.status = Set(status.to_string());
active.status = Set(DELIVERY_STATUS_EXHAUSTED.to_string());
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
active.response_text = Set(Some(error.to_string()));
active.response_text = Set(Some(error_text.clone()));
active.attempts_count = Set(attempts);
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
active.next_retry_at = Set(next_retry_at);
active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, subscription_id, status, false).await?;
update_subscription_delivery_state(
ctx,
subscription_id,
DELIVERY_STATUS_EXHAUSTED,
false,
)
.await?;
maybe_pause_failed_web_push_subscription(ctx, subscription.as_ref(), &error_text)
.await?;
Err(error)?;
}
}

View File

@@ -1,4 +1,5 @@
use loco_rs::prelude::*;
use reqwest::Url;
use serde_json::Value;
use web_push::{
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
@@ -46,17 +47,30 @@ pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
}
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
vapid_subject(settings)
.or_else(|| {
site_url
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
.map(ToString::to_string)
fn normalize_vapid_subject(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim();
if trimmed.starts_with("mailto:") || trimmed.starts_with("https://") {
Some(trimmed.to_string())
} else {
None
}
})
}
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
normalize_vapid_subject(vapid_subject(settings))
.or_else(|| normalize_vapid_subject(site_url.map(ToString::to_string)))
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
}
fn subscription_endpoint_host(subscription_info: &SubscriptionInfo) -> String {
Url::parse(&subscription_info.endpoint)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string())
}
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
public_key(settings).is_some()
}
@@ -90,10 +104,12 @@ pub async fn send_payload(
) -> Result<()> {
let private_key = private_key(settings)
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
let subject = effective_vapid_subject(settings, site_url);
let endpoint_host = subscription_endpoint_host(subscription_info);
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url));
signature_builder.add_claim("sub", subject.clone());
let signature = signature_builder
.build()
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
@@ -111,10 +127,11 @@ pub async fn send_payload(
.build()
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
client
.send(message)
.await
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?;
client.send(message).await.map_err(|error| {
Error::BadRequest(format!(
"web push send failed: {error}; vapid subject={subject}; endpoint_host={endpoint_host}"
))
})?;
Ok(())
}

View File

@@ -11,6 +11,7 @@ use crate::{
models::_entities::{notification_deliveries, worker_jobs},
services::subscriptions,
workers::{
ai_reindex::{AiReindexWorker, AiReindexWorkerArgs},
downloader::{DownloadWorker, DownloadWorkerArgs},
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
},
@@ -27,6 +28,7 @@ pub const JOB_STATUS_CANCELLED: &str = "cancelled";
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
pub const WORKER_AI_REINDEX: &str = "worker.ai_reindex";
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
@@ -164,6 +166,7 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
fn queue_name_for(worker_name: &str) -> Option<String> {
match worker_name {
WORKER_AI_REINDEX => Some("ai".to_string()),
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
@@ -174,6 +177,7 @@ fn queue_name_for(worker_name: &str) -> Option<String> {
fn label_for(worker_name: &str) -> String {
match worker_name {
WORKER_AI_REINDEX => "AI 索引重建".to_string(),
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
@@ -185,6 +189,7 @@ fn label_for(worker_name: &str) -> String {
fn description_for(worker_name: &str) -> String {
match worker_name {
WORKER_AI_REINDEX => "按当前站点内容重新生成 AI 检索索引,并分批写入向量数据。".to_string(),
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
@@ -196,6 +201,7 @@ fn description_for(worker_name: &str) -> String {
fn tags_for(worker_name: &str) -> Value {
match worker_name {
WORKER_AI_REINDEX => json!(["ai", "reindex"]),
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
@@ -249,6 +255,7 @@ fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
[
(WORKER_AI_REINDEX, JOB_KIND_WORKER, true, true),
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
@@ -313,6 +320,13 @@ async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
}
}
async fn dispatch_ai_reindex(args_ctx: AppContext, args: AiReindexWorkerArgs) {
let worker = AiReindexWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("ai reindex worker execution failed: {error}");
}
}
async fn dispatch_notification_delivery(
args_ctx: AppContext,
args: NotificationDeliveryWorkerArgs,
@@ -340,6 +354,11 @@ async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) ->
}
}
async fn enqueue_ai_reindex_worker(ctx: &AppContext, args: AiReindexWorkerArgs) -> Result<()> {
tokio::spawn(dispatch_ai_reindex(ctx.clone(), args));
Ok(())
}
async fn enqueue_notification_worker(
ctx: &AppContext,
args: NotificationDeliveryWorkerArgs,
@@ -587,6 +606,28 @@ pub async fn mark_job_succeeded(ctx: &AppContext, id: i32, result: Option<Value>
Ok(())
}
pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.result = Set(Some(result));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn cancel_job_if_requested(ctx: &AppContext, id: i32, reason: &str) -> Result<bool> {
let item = find_job(ctx, id).await?;
if item.status == JOB_STATUS_CANCELLED {
return Ok(true);
}
if item.cancel_requested {
finish_job_cancelled(ctx, id, Some(reason.to_string())).await?;
return Ok(true);
}
Ok(false)
}
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
@@ -681,6 +722,13 @@ pub async fn queue_notification_delivery_job(
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let mut delivery_active = delivery.clone().into_active_model();
delivery_active.status = Set(subscriptions::DELIVERY_STATUS_QUEUED.to_string());
delivery_active.response_text = Set(None);
delivery_active.next_retry_at = Set(None);
delivery_active.delivered_at = Set(None);
delivery_active.attempts_count = Set(0);
let delivery = delivery_active.update(&ctx.db).await?;
let base_args = NotificationDeliveryWorkerArgs {
delivery_id,
@@ -717,6 +765,46 @@ pub async fn queue_notification_delivery_job(
get_job_record(ctx, job.id).await
}
pub async fn queue_ai_reindex_job(
ctx: &AppContext,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let base_args = AiReindexWorkerArgs { job_id: None };
let payload = serde_json::to_value(&base_args)?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_AI_REINDEX.to_string(),
display_name: Some("重建 AI 索引".to_string()),
queue_name: queue_name_for(WORKER_AI_REINDEX),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_AI_REINDEX)),
related_entity_type: Some("ai_index".to_string()),
related_entity_id: Some("site".to_string()),
max_attempts: 1,
},
)
.await?;
enqueue_ai_reindex_worker(
ctx,
AiReindexWorkerArgs {
job_id: Some(job.id),
},
)
.await?;
get_job_record(ctx, job.id).await
}
pub async fn spawn_retry_deliveries_task(
ctx: &AppContext,
limit: Option<u64>,
@@ -810,6 +898,17 @@ pub async fn retry_job(
let payload = item.payload.clone().unwrap_or(Value::Null);
match item.worker_name.as_str() {
WORKER_AI_REINDEX => {
let _ = serde_json::from_value::<AiReindexWorkerArgs>(payload)?;
queue_ai_reindex_job(
ctx,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
WORKER_DOWNLOAD_MEDIA => {
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
queue_download_job(

View File

@@ -0,0 +1,77 @@
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::services::{ai, worker_jobs};
pub struct AiReindexWorker {
pub ctx: AppContext,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AiReindexWorkerArgs {
#[serde(default)]
pub job_id: Option<i32>,
}
#[async_trait]
impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
fn tags() -> Vec<String> {
vec!["ai".to_string(), "reindex".to_string()]
}
async fn perform(&self, args: AiReindexWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match ai::rebuild_index(&self.ctx, Some(job_id)).await {
Ok(summary) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::json!({
"phase": "completed",
"message": "AI 索引重建完成。",
"progress": {
"phase": "completed",
"message": "AI 索引重建完成。",
"total_chunks": summary.indexed_chunks,
"processed_chunks": summary.indexed_chunks,
"total_batches": summary.indexed_chunks.div_ceil(ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1)),
"current_batch": summary.indexed_chunks.div_ceil(ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1)),
"batch_size": ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1),
"percent": 100,
},
"indexed_chunks": summary.indexed_chunks,
"last_indexed_at": summary.last_indexed_at.map(|value| value.to_rfc3339()),
})),
)
.await?;
Ok(())
}
Err(error) => {
if worker_jobs::cancel_job_if_requested(
&self.ctx,
job_id,
"job cancelled during reindex",
)
.await?
{
return Ok(());
}
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
ai::rebuild_index(&self.ctx, None).await?;
Ok(())
}
}
}

View File

@@ -1,2 +1,3 @@
pub mod ai_reindex;
pub mod downloader;
pub mod notification_delivery;

View File

@@ -20,10 +20,6 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
Self { ctx: ctx.clone() }
}
fn tags() -> Vec<String> {
vec!["notifications".to_string()]
}
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {

View File

@@ -3,6 +3,17 @@ BACKEND_PORT=5150
FRONTEND_PORT=4321
ADMIN_PORT=4322
# 建议在小内存主机上给每个服务设置明确上限,避免 backend 在 AI 重建索引时
# 把整台主机拖进 swap 抖动。默认值与 compose.package.yml 保持一致。
BACKEND_MEMORY_LIMIT=768m
BACKEND_MEMORY_SWAP_LIMIT=768m
BACKEND_WORKER_MEMORY_LIMIT=1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT=1g
FRONTEND_MEMORY_LIMIT=256m
FRONTEND_MEMORY_SWAP_LIMIT=256m
ADMIN_MEMORY_LIMIT=128m
ADMIN_MEMORY_SWAP_LIMIT=128m
# frontend SSR 服务端访问 backend 用这个内部地址compose 默认可直接使用)
INTERNAL_API_BASE_URL=http://backend:5150/api

View File

@@ -74,6 +74,11 @@ backend-worker
如果只启动 `backend` 而没有 `backend-worker`,通知会入队但没人消费。
补充说明:
- `backend-worker` 目前主要消费 Redis 队列里的通知相关任务。
- AI 索引重建会直接在 `backend` 进程本地启动,这样创建任务后会立即进入执行,不再依赖独立 worker 消费。
## 2.1 推荐的后台认证链路
当前最推荐:

View File

@@ -43,6 +43,10 @@ python deploy/scripts/render_compose_env.py \
建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量:
- `BACKEND_MEMORY_LIMIT / BACKEND_MEMORY_SWAP_LIMIT`backend 容器内存 / swap 上限;对小内存主机建议显式设置
- `BACKEND_WORKER_MEMORY_LIMIT / BACKEND_WORKER_MEMORY_SWAP_LIMIT`worker 容器内存 / swap 上限
- `FRONTEND_MEMORY_LIMIT / FRONTEND_MEMORY_SWAP_LIMIT`frontend 容器内存 / swap 上限
- `ADMIN_MEMORY_LIMIT / ADMIN_MEMORY_SWAP_LIMIT`admin 容器内存 / swap 上限
- `INTERNAL_API_BASE_URL`frontend SSR 容器访问 backend 用compose 默认推荐 `http://backend:5150/api`
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
@@ -62,6 +66,14 @@ python deploy/scripts/render_compose_env.py \
```yaml
compose_env:
BACKEND_MEMORY_LIMIT: 768m
BACKEND_MEMORY_SWAP_LIMIT: 768m
BACKEND_WORKER_MEMORY_LIMIT: 1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
FRONTEND_MEMORY_LIMIT: 256m
FRONTEND_MEMORY_SWAP_LIMIT: 256m
ADMIN_MEMORY_LIMIT: 128m
ADMIN_MEMORY_SWAP_LIMIT: 128m
PUBLIC_API_BASE_URL: https://api.blog.init.cool
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
@@ -136,6 +148,7 @@ A: 当前站点对外内容页优先 SEO 与首屏可见性,保留 SSR 更稳
A: 推荐前置 Caddy/Nginx 统一暴露 `80/443``frontend:4321` / `backend:5150` / `admin:80` 仅走内网。
当前 `compose.package.yml` 属于直连端口版,便于快速部署与联调。
另外因为通知已经走异步队列,生产务必同时启动 `backend-worker`
AI 索引重建当前直接在 `backend` 进程本地启动,不依赖 `backend-worker` 消费 Redis 队列。
### Q5: 为什么 compose 里没看到 `ADMIN_VITE_FRONTEND_BASE_URL`
A:
@@ -178,6 +191,7 @@ A:
A:
- `backend` 镜像启动时会先执行 `db migrate`
- `backend` 提供 `/healthz`
- `backend-worker` 不提供 HTTP `/healthz`compose 会覆盖镜像默认 healthcheck改为检查主进程是否仍以 `--worker` 模式运行
- `frontend` 提供 `/healthz`
- `admin` 继续由 Nginx 提供 `/healthz`
- compose 现在使用 `depends_on.condition: service_healthy`

View File

@@ -3,6 +3,10 @@ services:
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always
restart: unless-stopped
# 对 tohka 这类小内存主机,建议给服务设置明确上限,
# 避免 AI 重建索引时把整机拖进 swap 抖动 / OOM。
mem_limit: ${BACKEND_MEMORY_LIMIT:-768m}
memswap_limit: ${BACKEND_MEMORY_SWAP_LIMIT:-768m}
environment:
PORT: 5150
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150}
@@ -30,6 +34,8 @@ services:
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${BACKEND_WORKER_MEMORY_LIMIT:-1g}
memswap_limit: ${BACKEND_WORKER_MEMORY_SWAP_LIMIT:-1g}
depends_on:
backend:
condition: service_healthy
@@ -48,11 +54,22 @@ services:
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
RUST_LOG: ${RUST_LOG:-info}
TERMI_SKIP_MIGRATIONS: 'true'
# backend 镜像默认 healthcheck 会探测 HTTP /healthz
# 但 worker 模式不监听 5150所以这里改成“主进程仍然是 --worker”检查。
healthcheck:
test:
['CMD-SHELL', "test -r /proc/1/cmdline && tr '\\000' ' ' </proc/1/cmdline | grep -q -- '--worker'"]
interval: 30s
timeout: 3s
start_period: 15s
retries: 5
frontend:
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${FRONTEND_MEMORY_LIMIT:-256m}
memswap_limit: ${FRONTEND_MEMORY_SWAP_LIMIT:-256m}
depends_on:
backend:
condition: service_healthy
@@ -78,6 +95,8 @@ services:
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${ADMIN_MEMORY_LIMIT:-128m}
memswap_limit: ${ADMIN_MEMORY_SWAP_LIMIT:-128m}
depends_on:
backend:
condition: service_healthy

View File

@@ -25,6 +25,14 @@ compose_env:
BACKEND_PORT: 5150
FRONTEND_PORT: 4321
ADMIN_PORT: 4322
BACKEND_MEMORY_LIMIT: 768m
BACKEND_MEMORY_SWAP_LIMIT: 768m
BACKEND_WORKER_MEMORY_LIMIT: 1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
FRONTEND_MEMORY_LIMIT: 256m
FRONTEND_MEMORY_SWAP_LIMIT: 256m
ADMIN_MEMORY_LIMIT: 128m
ADMIN_MEMORY_SWAP_LIMIT: 128m
APP_BASE_URL: https://admin.blog.init.cool
INTERNAL_API_BASE_URL: http://backend:5150/api

View File

@@ -105,8 +105,8 @@ function Start-Backend {
-Run {
$env:DATABASE_URL = $DatabaseUrl
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green
cargo loco start 2>&1
Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
cargo loco start --server-and-worker 2>&1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,9 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<defs>
<linearGradient id="bg" x1="18" y1="10" x2="110" y2="118" gradientUnits="userSpaceOnUse">
<stop stop-color="#132334" />
<stop offset="1" stop-color="#081019" />
</linearGradient>
<linearGradient id="toolbar" x1="20" y1="18" x2="108" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#24384B" />
<stop offset="1" stop-color="#162633" />
</linearGradient>
<linearGradient id="prompt" x1="40" y1="44" x2="92" y2="92" gradientUnits="userSpaceOnUse">
<stop stop-color="#9AF7E3" />
<stop offset="1" stop-color="#48CFFF" />
</linearGradient>
<filter id="glow" x="18" y="28" width="92" height="78" filterUnits="userSpaceOnUse">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<rect x="10" y="10" width="108" height="108" rx="28" fill="url(#bg)" />
<rect x="11.5" y="11.5" width="105" height="105" rx="26.5" stroke="#5FE1FF" stroke-opacity=".18" stroke-width="3" />
<path d="M10 38c0-15.464 12.536-28 28-28h52c15.464 0 28 12.536 28 28v2H10v-2Z" fill="url(#toolbar)" />
<circle cx="28" cy="26" r="5.5" fill="#FF6B6B" />
<circle cx="44" cy="26" r="5.5" fill="#FFD166" />
<circle cx="60" cy="26" r="5.5" fill="#57D68D" />
<g filter="url(#glow)">
<path
d="M37 50 65 64 37 78 31 71.5 48.5 64 31 56.5 37 50Z"
fill="url(#prompt)"
/>
<rect x="69" y="74" width="26" height="8.5" rx="4.25" fill="url(#prompt)" />
</g>
<path
d="M86 18h17c4.418 0 8 3.582 8 8v17l-25-25Z"
fill="#1DE9B6"
fill-opacity=".16"
/>
<circle cx="99" cy="29" r="8" fill="#0B1621" stroke="#1DE9B6" stroke-width="2.5" />
<path d="M96 29h6" stroke="#1DE9B6" stroke-linecap="round" stroke-width="2.75" />
<path d="M99 26v6" stroke="#1DE9B6" stroke-linecap="round" stroke-width="2.75" />
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,4 +1,4 @@
self.addEventListener('push', (event) => {
self.addEventListener("push", (event) => {
const payload = (() => {
if (!event.data) {
return {};
@@ -13,30 +13,50 @@ self.addEventListener('push', (event) => {
}
})();
const title = payload.title || '订阅更新';
const url = payload.url || '/';
const notifyClients = self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) =>
Promise.all(
clients.map((client) =>
client.postMessage({
type: "termi:web-push-received",
payload,
}),
),
),
);
const title = payload.title || "订阅更新";
const url = payload.url || "/";
const options = {
body: payload.body || '',
icon: payload.icon || '/favicon.svg',
badge: payload.badge || '/favicon.ico',
tag: payload.tag || 'termi-subscription',
body: payload.body || "",
icon: payload.icon || "/favicon.svg",
badge: payload.badge || "/favicon.ico",
tag: payload.tag || "termi-subscription",
data: {
url,
...(payload.data || {}),
},
};
event.waitUntil(self.registration.showNotification(title, options));
event.waitUntil(
Promise.all([
self.registration.showNotification(title, options),
notifyClients,
]),
);
});
self.addEventListener('notificationclick', (event) => {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification?.data?.url || '/';
const targetUrl = event.notification?.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ('focus' in client && client.url === targetUrl) {
if ("focus" in client && client.url === targetUrl) {
return client.focus();
}
}

View File

@@ -17,7 +17,8 @@ const {
const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter(
const configuredMusicPlaylist = Astro.props.siteSettings?.musicPlaylist ?? [];
const musicPlaylist = (musicEnabled ? configuredMusicPlaylist : []).filter(
(item) => item?.title?.trim() && item?.url?.trim()
);
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
@@ -34,6 +35,14 @@ const navItems = [
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const mobileDockItems = [
{ icon: 'fa-house', text: t('common.home'), href: '/' },
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
...(aiEnabled
? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }]
: [{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }]),
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
];
const localeLinks = SUPPORTED_LOCALES.map((item) => ({
locale: item,
href: buildLocaleUrl(item),
@@ -148,7 +157,7 @@ const currentNavLabel =
{aiEnabled && (
<a
href="/ask"
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_28%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.10)] transition hover:border-[color:color-mix(in_oklab,var(--primary)_40%,var(--border-color))] hover:text-[var(--title-color)]"
>
<i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span>
@@ -356,6 +365,40 @@ const currentNavLabel =
</div>
</header>
<div class="fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.8rem+env(safe-area-inset-bottom))] lg:hidden">
<div class="mx-auto max-w-md">
<div class="grid grid-cols-5 gap-1 rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_96%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent))] p-1.5 shadow-[0_18px_36px_rgba(15,23,42,0.22)] backdrop-blur-xl">
{mobileDockItems.map((item) => {
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
return (
<a
href={item.href}
class:list={[
'flex min-w-0 flex-col items-center gap-1 rounded-[18px] px-2 py-2 text-[11px] font-medium transition',
isActive
? 'border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] text-[var(--primary)]'
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]',
]}
aria-current={isActive ? 'page' : undefined}
>
<i class={`fas ${item.icon} text-[13px]`}></i>
<span class="truncate">{item.text}</span>
</a>
);
})}
<button
type="button"
class="flex min-w-0 flex-col items-center gap-1 rounded-[18px] border border-transparent px-2 py-2 text-[11px] font-medium text-[var(--text-secondary)] transition hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]"
data-mobile-dock-menu
aria-label={t('header.toggleMenu')}
>
<i class="fas fa-bars text-[13px]"></i>
<span class="truncate">{t('header.navigation')}</span>
</button>
</div>
</div>
</div>
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
const t = window.__termiTranslate;
@@ -364,12 +407,16 @@ const currentNavLabel =
const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input');
const mobileSearchBtn = document.getElementById('mobile-search-btn');
const mobileDockMenuBtn = document.querySelector('[data-mobile-dock-menu]');
mobileMenuBtn?.addEventListener('click', () => {
function toggleMobileMenu() {
const nextExpanded = mobileMenu?.classList.contains('hidden');
mobileMenu?.classList.toggle('hidden');
mobileMenuBtn.setAttribute('aria-expanded', String(nextExpanded));
});
mobileMenuBtn?.setAttribute('aria-expanded', String(nextExpanded));
}
mobileMenuBtn?.addEventListener('click', toggleMobileMenu);
mobileDockMenuBtn?.addEventListener('click', toggleMobileMenu);
document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
link.addEventListener('click', () => {

View File

@@ -10,10 +10,10 @@ const { stats } = Astro.props;
<ul class="grid gap-3">
{stats.map((stat, index) => (
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
<li class="terminal-panel-muted terminal-panel-accent terminal-interactive-card rounded-2xl px-4 py-4" style="--accent-rgb: var(--primary-rgb); --accent-color: var(--primary);">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-transparent bg-[var(--primary)] text-[var(--terminal-bg)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.22)]">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_16%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_18%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_10%,var(--header-bg)))] text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--text-rgb),0.06)]">
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
</span>
<div class="min-w-0">
@@ -21,7 +21,7 @@ const { stats } = Astro.props;
<div class="mt-1 text-lg font-semibold text-[var(--title-color)]">{stat.value}</div>
</div>
</div>
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.3),transparent)]"></span>
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.22),transparent)]"></span>
</div>
</li>
))}

View File

@@ -155,13 +155,31 @@ const webPushAvailable = Boolean(webPushPublicKey);
<i class="fas fa-envelope-open-text"></i>
</span>
<span class="subscription-popup-channel-toggle-copy">
<span class="subscription-popup-channel-toggle-meta" aria-hidden="true">
<span class="subscription-popup-channel-toggle-tag">可选</span>
<span class="subscription-popup-channel-toggle-tag subscription-popup-channel-toggle-tag--email">
Email
</span>
</span>
<span class="subscription-popup-channel-toggle-title" data-subscription-popup-channel-toggle-label>
开启邮件订阅
添加邮件订阅
</span>
<span class="subscription-popup-channel-toggle-description">
需要时再补一个邮箱备份
<span
class="subscription-popup-channel-toggle-description"
data-subscription-popup-channel-toggle-description
>
填写邮箱后,更新也会发到你的收件箱
</span>
</span>
<span class="subscription-popup-channel-toggle-affordance" aria-hidden="true">
<span
class="subscription-popup-channel-toggle-affordance-text"
data-subscription-popup-channel-toggle-affordance
>
去填写
</span>
<i class="fas fa-chevron-right"></i>
</span>
</button>
</div>
</div>
@@ -342,7 +360,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
import {
ensureBrowserPushSubscription,
getBrowserPushSubscription,
getBrowserPushSubscriptionState,
supportsBrowserPush,
} from '../lib/utils/web-push';
@@ -376,6 +394,12 @@ const webPushAvailable = Boolean(webPushPublicKey);
const emailToggleLabel = emailToggleButton?.querySelector(
'[data-subscription-popup-channel-toggle-label]',
) as HTMLElement | null;
const emailToggleDescription = emailToggleButton?.querySelector(
'[data-subscription-popup-channel-toggle-description]',
) as HTMLElement | null;
const emailToggleAffordance = emailToggleButton?.querySelector(
'[data-subscription-popup-channel-toggle-affordance]',
) as HTMLElement | null;
const browserCard = root.querySelector(
'[data-subscription-popup-channel-card="browser"]',
) as HTMLElement | null;
@@ -481,6 +505,14 @@ const webPushAvailable = Boolean(webPushPublicKey);
}
};
const forgetSubmitted = () => {
try {
window.localStorage.removeItem(SUBSCRIBED_KEY);
} catch {
// Ignore storage failures.
}
};
const resetStatus = () => {
delete status.dataset.state;
status.textContent = defaultStatus;
@@ -572,7 +604,17 @@ const webPushAvailable = Boolean(webPushPublicKey);
emailInput.required = emailSelected;
if (emailToggleLabel instanceof HTMLElement) {
emailToggleLabel.textContent = emailSelected ? '收起邮件订阅' : '开启邮件订阅';
emailToggleLabel.textContent = emailSelected ? '收起邮件订阅' : '添加邮件订阅';
}
if (emailToggleDescription instanceof HTMLElement) {
emailToggleDescription.textContent = emailSelected
? '邮箱表单已经展开,填好后提交即可作为额外备份'
: '填写邮箱后,更新也会发到你的收件箱';
}
if (emailToggleAffordance instanceof HTMLElement) {
emailToggleAffordance.textContent = emailSelected ? '收起' : '去填写';
}
setToggleState(emailToggleButton, emailSelected, !browserRequired);
@@ -777,7 +819,19 @@ const webPushAvailable = Boolean(webPushPublicKey);
}
try {
const subscription = await getBrowserPushSubscription();
const { subscription, stale } = await getBrowserPushSubscriptionState(
browserPushPublicKey,
);
if (stale) {
forgetSubmitted();
setBrowserAvailability({
selectable: true,
note: '检测到提醒配置已更新,需要重新开启一次提醒。',
});
return;
}
if (subscription) {
rememberSubmitted();
setBrowserAvailability({
@@ -1021,7 +1075,8 @@ const webPushAvailable = Boolean(webPushPublicKey);
gap: 1rem;
margin-top: var(--subscription-popup-offset, calc(env(safe-area-inset-top, 0px) + 5.25rem));
padding: 1.1rem;
border-radius: 1.7rem;
padding-top: 3.5rem;
border-radius: 1.55rem;
opacity: 0;
transform: translateY(-1rem) scale(0.985);
transition:
@@ -1031,16 +1086,20 @@ const webPushAvailable = Boolean(webPushPublicKey);
overflow: hidden;
backdrop-filter: blur(16px) saturate(135%);
background:
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.15), transparent 26%),
radial-gradient(circle at bottom right, rgba(var(--secondary-rgb, var(--primary-rgb)), 0.1), transparent 28%),
linear-gradient(
135deg,
rgba(var(--primary-rgb), 0.09),
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.04) 42%,
transparent 72%
),
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 99%, white),
color-mix(in oklab, var(--header-bg) 93%, white)
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 92%, transparent)
);
box-shadow:
0 28px 70px rgba(var(--text-rgb), 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.subscription-popup-panel::before {
@@ -1066,20 +1125,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
position: absolute;
top: 0.8rem;
right: 0.8rem;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 94%, transparent);
border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--border-color));
background: color-mix(in oklab, var(--header-bg) 90%, var(--terminal-bg));
color: var(--text-tertiary);
cursor: pointer;
transition:
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow:
0 10px 22px rgba(var(--text-rgb), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.subscription-popup-close:hover {
@@ -1097,23 +1161,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-main {
display: grid;
gap: 1rem;
position: relative;
z-index: 1;
}
.subscription-popup-copy-surface,
.subscription-popup-channel-card {
position: relative;
overflow: hidden;
border-radius: 1.35rem;
border-radius: 1.2rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 99%, white),
color-mix(in oklab, var(--header-bg) 94%, white)
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 91%, transparent)
);
box-shadow:
0 12px 30px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.subscription-popup-copy-surface {
@@ -1167,7 +1233,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
min-height: 3.35rem;
border-radius: 1rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 98%, white);
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary);
padding: 0.85rem 1rem;
text-align: left;
@@ -1191,13 +1257,50 @@ const webPushAvailable = Boolean(webPushPublicKey);
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
.subscription-popup-channel-toggle-copy {
display: grid;
gap: 0.18rem;
min-width: 0;
flex: 1;
}
.subscription-popup-channel-toggle-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.1rem;
}
.subscription-popup-channel-toggle-tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--border-color) 88%, transparent);
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
color: var(--text-tertiary);
padding: 0.15rem 0.45rem;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.subscription-popup-channel-toggle-tag--email {
color: color-mix(in oklab, var(--secondary, var(--primary)) 62%, var(--title-color));
border-color: color-mix(
in oklab,
var(--secondary, var(--primary)) 22%,
var(--border-color)
);
background: color-mix(
in oklab,
var(--secondary, var(--primary)) 11%,
var(--terminal-bg)
);
}
.subscription-popup-channel-toggle-title {
@@ -1215,6 +1318,27 @@ const webPushAvailable = Boolean(webPushPublicKey);
line-height: 1.55;
}
.subscription-popup-channel-toggle-affordance {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-left: auto;
align-self: center;
color: color-mix(in oklab, var(--secondary, var(--primary)) 58%, var(--title-color));
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
transition:
color 0.2s ease,
transform 0.2s ease;
}
.subscription-popup-channel-toggle-affordance i {
font-size: 0.72rem;
transition: transform 0.2s ease;
}
.subscription-popup-channel-toggle:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
@@ -1222,6 +1346,10 @@ const webPushAvailable = Boolean(webPushPublicKey);
box-shadow: 0 14px 28px rgba(var(--text-rgb), 0.08);
}
.subscription-popup-channel-toggle:hover .subscription-popup-channel-toggle-affordance {
transform: translateX(1px);
}
.subscription-popup-channel-toggle.is-active {
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
background:
@@ -1240,12 +1368,16 @@ const webPushAvailable = Boolean(webPushPublicKey);
background: color-mix(in oklab, var(--primary) 16%, var(--terminal-bg));
}
.subscription-popup-channel-toggle.is-active .subscription-popup-channel-toggle-affordance i {
transform: rotate(90deg);
}
.subscription-popup-channel-toggle--primary {
border-color: color-mix(in oklab, var(--primary) 52%, white);
border-color: color-mix(in oklab, var(--primary) 48%, var(--border-color));
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--primary) 88%, white),
color-mix(in oklab, var(--primary) 82%, white),
color-mix(in oklab, var(--primary) 72%, var(--header-bg))
);
color: white;
@@ -1285,7 +1417,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--secondary, var(--primary)) 26%, white),
color-mix(in oklab, var(--secondary, var(--primary)) 22%, var(--terminal-bg)),
color-mix(in oklab, var(--secondary, var(--primary)) 14%, var(--header-bg))
);
box-shadow:
@@ -1490,11 +1622,11 @@ const webPushAvailable = Boolean(webPushPublicKey);
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--header-bg) 96%, white),
color-mix(in oklab, var(--terminal-bg) 98%, white)
color-mix(in oklab, var(--header-bg) 94%, transparent),
color-mix(in oklab, var(--terminal-bg) 97%, transparent)
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 14px 32px rgba(var(--text-rgb), 0.05);
}
@@ -1589,7 +1721,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
.subscription-popup-channel-icon--mail {
@@ -1615,7 +1747,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
margin-top: 0.15rem;
border-radius: 1.05rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--header-bg) 65%, white);
background: color-mix(in oklab, var(--header-bg) 88%, var(--terminal-bg));
padding: 0.9rem 0.95rem;
}
@@ -1736,7 +1868,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-panel {
gap: 0.9rem;
padding: 0.95rem 0.9rem 0.95rem;
padding: 3.1rem 0.9rem 0.95rem;
}
.subscription-popup-copy-mark {
@@ -1756,7 +1888,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-copy-surface,
.subscription-popup-channel-card {
border-radius: 1.2rem;
border-radius: 1.05rem;
}
.subscription-popup-copy-surface {

View File

@@ -16,7 +16,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
<div
id="toc-panel"
class="rounded-[24px] border border-[var(--border-color)]/72 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,252,255,0.92))] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.055)] backdrop-blur"
class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.045),transparent_56%)] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.08)] backdrop-blur"
>
<div class="space-y-4">
<span class="terminal-kicker w-fit">
@@ -130,8 +130,8 @@ const hasBeforeNav = Astro.slots.has('before-nav');
height: 0.52rem;
margin-top: 0.42rem;
border-radius: 999px;
background: color-mix(in oklab, var(--border-color) 82%, white 18%);
box-shadow: 0 0 0 6px color-mix(in oklab, var(--card-bg, white) 92%, transparent);
background: color-mix(in oklab, var(--border-color) 82%, var(--terminal-bg));
box-shadow: 0 0 0 6px color-mix(in oklab, var(--terminal-bg) 92%, transparent);
transition:
background-color 160ms ease,
transform 160ms ease,
@@ -145,7 +145,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
#toc-nav .toc-link.is-active .toc-link-dot {
background: var(--primary);
transform: scale(1.05);
box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, white 90%);
box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
}
#toc-nav .toc-link-sub .toc-link-dot {
@@ -189,6 +189,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
const tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
const header = document.querySelector('header');
if (!tocNav || headings.length === 0) {
if (tocPanel) tocPanel.style.display = 'none';
@@ -196,6 +197,47 @@ const hasBeforeNav = Astro.slots.has('before-nav');
return;
}
const getScrollOffset = () => {
const headerRect = header instanceof HTMLElement ? header.getBoundingClientRect() : null;
const headerHeight = headerRect ? Math.max(headerRect.height, headerRect.bottom) : 0;
return Math.round(headerHeight + 20);
};
const updateHeadingOffset = () => {
const offset = `${getScrollOffset()}px`;
headings.forEach((heading) => {
if (heading instanceof HTMLElement) {
heading.style.scrollMarginTop = offset;
}
});
};
const scrollToHeading = (heading) => {
const offset = getScrollOffset();
const targetTop = window.scrollY + heading.getBoundingClientRect().top - offset;
window.scrollTo({
top: Math.max(targetTop, 0),
behavior: 'smooth',
});
};
const setActiveLink = (headingId) => {
const links = tocNav.querySelectorAll('a');
links.forEach((link) => {
const isActive = link.getAttribute('href') === `#${headingId}`;
link.classList.toggle('is-active', isActive);
if (isActive) {
link.setAttribute('aria-current', 'true');
link.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
link.removeAttribute('aria-current');
}
});
};
tocNav.innerHTML = '';
headings.forEach((heading, index) => {
if (!heading.id) {
@@ -212,32 +254,103 @@ const hasBeforeNav = Astro.slots.has('before-nav');
link.addEventListener('click', (e) => {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveLink(heading.id);
scrollToHeading(heading);
window.history.replaceState(null, '', `#${heading.id}`);
});
tocNav.appendChild(link);
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const links = tocNav.querySelectorAll('a');
links.forEach(link => {
link.classList.remove('is-active');
link.removeAttribute('aria-current');
if (link.getAttribute('href') === `#${entry.target.id}`) {
link.classList.add('is-active');
link.setAttribute('aria-current', 'true');
}
});
}
});
},
{ rootMargin: '-20% 0px -75% 0px' }
);
updateHeadingOffset();
headings.forEach(heading => observer.observe(heading));
let ticking = false;
let scrollEndTimer = 0;
let suppressScrollTracking = false;
const syncActiveHeading = () => {
if (suppressScrollTracking) {
return;
}
const offset = getScrollOffset();
const threshold = offset + 24;
const headingList = Array.from(headings).filter((heading) => heading instanceof HTMLElement);
let activeHeading = headingList[0] || null;
headingList.forEach((heading) => {
if (heading.getBoundingClientRect().top <= threshold) {
activeHeading = heading;
}
});
if (!activeHeading) {
return;
}
const nearBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
if (nearBottom) {
activeHeading = headingList[headingList.length - 1] || activeHeading;
}
setActiveLink(activeHeading.id);
};
const requestActiveHeadingSync = () => {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(() => {
ticking = false;
syncActiveHeading();
});
};
const releaseScrollTrackingSoon = () => {
window.clearTimeout(scrollEndTimer);
scrollEndTimer = window.setTimeout(() => {
suppressScrollTracking = false;
syncActiveHeading();
}, 220);
};
window.addEventListener('scroll', () => {
releaseScrollTrackingSoon();
requestActiveHeadingSync();
}, { passive: true });
window.addEventListener('resize', () => {
updateHeadingOffset();
requestActiveHeadingSync();
}, { passive: true });
if (window.location.hash) {
const target = document.getElementById(window.location.hash.slice(1));
if (target instanceof HTMLElement) {
requestAnimationFrame(() => {
suppressScrollTracking = true;
setActiveLink(target.id);
scrollToHeading(target);
releaseScrollTrackingSoon();
});
}
} else if (headings[0] instanceof HTMLElement) {
setActiveLink(headings[0].id);
}
tocNav.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
suppressScrollTracking = true;
releaseScrollTrackingSoon();
});
});
requestAnimationFrame(syncActiveHeading);
}
if (document.readyState === 'loading') {

View File

@@ -10,9 +10,9 @@ const { items } = Astro.props;
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
<li class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group overflow-hidden rounded-2xl" style="--accent-rgb: var(--primary-rgb); --accent-color: var(--primary);">
<div class="flex items-start gap-3 px-4 py-4">
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_14%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_16%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_9%,var(--header-bg)))] text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--text-rgb),0.06)]">
<i class="fas fa-code text-xs"></i>
</span>
<span class="min-w-0 flex-1">

View File

@@ -31,7 +31,7 @@ const {
title = isEnglish ? 'Quick share' : '一键分享',
description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
: '复制链接、带走二维码,轻轻一发,不剧透太多。',
stats = [],
wechatShareQrEnabled = false,
variant = 'default',
@@ -70,30 +70,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready',
}
: {
summaryTitle: '页面简介',
canonical: '固定链接',
copySummary: '复制简介',
copySummarySuccess: '页面简介已复制',
summaryTitle: '小纸条',
canonical: '传送门',
copySummary: '复制小纸条',
copySummarySuccess: '小纸条已复制',
copySummaryFailed: '复制失败',
copyLink: '复制固定链接',
copyLinkSuccess: '固定链接已复制',
copyLinkFailed: '固定链接复制失败',
shareSummary: '直接分享',
shareSuccess: '已打开系统分享',
shareFallback: '分享内容已复制',
copyLink: '复制传送门',
copyLinkSuccess: '传送门已复制',
copyLinkFailed: '传送门复制失败',
shareSummary: '一键甩出',
shareSuccess: '系统分享已就位',
shareFallback: '分享话术已复制',
shareFailed: '分享失败',
shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫',
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。',
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
qrModalDescription: '扫一下,手机上继续逛。',
qrModalHint: '真要转发,丢链接通常更省事。',
downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败',
toastInfoTitle: '已准备好',
toastSuccessTitle: '搞定',
toastErrorTitle: '这次没接住',
toastInfoTitle: '可以发了',
};
const safeSummary = summary.trim() || shareTitle;
@@ -140,7 +140,9 @@ if (wechatShareQrEnabled) {
<section
class:list={[
'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))]',
'border shadow-[0_16px_40px_rgba(15,23,42,0.08)]',
'border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))]',
'bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)]',
isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6',
]}
data-share-panel-id={panelId}
@@ -148,7 +150,7 @@ if (wechatShareQrEnabled) {
<div class:list={['flex flex-col gap-4', !isCompact && '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)]">
<span class="inline-flex items-center gap-2 rounded-full border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_15%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]">
<i class="fas fa-satellite-dish text-[10px]"></i>
{visibleBadge}
</span>
@@ -167,7 +169,7 @@ if (wechatShareQrEnabled) {
{stats.length > 0 ? (
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && '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="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_96%,transparent)] 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>
@@ -177,7 +179,7 @@ if (wechatShareQrEnabled) {
</div>
<div class:list={[
'rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 shadow-[0_16px_40px_rgba(15,23,42,0.06)]',
'rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] shadow-[0_16px_40px_rgba(15,23,42,0.06)]',
isCompact ? 'mt-4 p-4' : 'mt-5 p-5',
]}>
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
@@ -186,6 +188,91 @@ if (wechatShareQrEnabled) {
</p>
</div>
{isCompact ? (
<div class="share-panel-compact-actions mt-4">
<div class="share-panel-compact-actions__head">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share actions' : '分享操作'}
</p>
</div>
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--utility">
<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>
</div>
<div class="share-panel-compact-actions__primary">
<button
type="button"
class="terminal-action-button terminal-action-button-primary w-full"
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>
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--channels">
<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 share-panel-compact-actions__wechat"
data-share-wechat-open
>
<i class="fab fa-weixin"></i>
<span>{copy.shareToWeChat}</span>
</button>
) : null}
</div>
<p class="share-panel-compact-actions__status text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></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
@@ -226,13 +313,13 @@ if (wechatShareQrEnabled) {
<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="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] 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>
{!isCompact && <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>}
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
</div>
<div class="flex flex-wrap gap-2">
<a
@@ -268,20 +355,24 @@ if (wechatShareQrEnabled) {
</div>
</div>
</div>
</>
)}
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<div class="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] 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"
class="share-wechat-modal-overlay fixed inset-0 z-[160] hidden"
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-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
<div class="share-wechat-modal-card relative isolate w-full max-w-4xl overflow-hidden">
<div class="share-wechat-modal-card__line absolute inset-x-0 top-0 h-px"></div>
<div class="relative z-[1] p-5 sm:p-7">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker">
@@ -298,7 +389,7 @@ if (wechatShareQrEnabled) {
<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)]"
class="share-wechat-modal-close flex h-11 w-11 items-center justify-center rounded-2xl text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-share-wechat-close
aria-label={t('common.close')}
>
@@ -307,19 +398,19 @@ if (wechatShareQrEnabled) {
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
<div class="share-wechat-modal-surface mx-auto w-full max-w-[260px] rounded-[28px] p-5">
<div class="overflow-hidden rounded-2xl bg-white" set:html={wechatShareQrSvg}></div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical}
</div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle}
</div>
@@ -363,6 +454,7 @@ if (wechatShareQrEnabled) {
</div>
</div>
</div>
</div>
) : null}
<div
@@ -393,6 +485,118 @@ if (wechatShareQrEnabled) {
</div>
</section>
<style is:inline>
.share-panel-compact-actions {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
padding: 0.95rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 90%, transparent)
);
box-shadow:
0 14px 34px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.share-panel-compact-actions__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.share-panel-compact-actions__grid {
display: grid;
gap: 0.5rem;
}
.share-panel-compact-actions__grid--utility {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.share-panel-compact-actions__grid--channels {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.share-panel-compact-actions__grid--channels > :last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.share-panel-compact-actions__primary .terminal-action-button,
.share-panel-compact-actions__grid .terminal-action-button {
min-width: 0;
width: 100%;
}
.share-panel-compact-actions__grid .terminal-action-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-panel-compact-actions__status {
min-height: 1rem;
margin: -0.1rem 0 0;
}
.share-wechat-modal-card {
border-radius: 32px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background-color: var(--terminal-bg);
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.36);
}
.share-wechat-modal-card::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 92%, var(--bg)),
color-mix(in oklab, var(--terminal-bg) 84%, var(--bg))
),
linear-gradient(
135deg,
rgba(var(--primary-rgb), 0.055),
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.03) 46%,
transparent 82%
);
opacity: 1;
}
.share-wechat-modal-overlay {
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(10px);
}
.share-wechat-modal-card__line {
background: linear-gradient(90deg, transparent, rgba(var(--primary-rgb), 0.4), transparent);
}
.share-wechat-modal-close {
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, white 3%);
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
.share-wechat-modal-surface {
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background-color: color-mix(in oklab, var(--terminal-bg) 90%, var(--bg));
box-shadow:
0 16px 36px rgba(var(--text-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
</style>
<script
is:inline
define:vars={{
@@ -424,6 +628,10 @@ if (wechatShareQrEnabled) {
const toastMessage = root.querySelector('[data-share-toast-message]');
let toastTimer = 0;
if (wechatModal instanceof HTMLElement && wechatModal.parentElement !== document.body) {
document.body.appendChild(wechatModal);
}
function setStatus(message) {
if (!status) return;
status.textContent = message || '';

View File

@@ -150,7 +150,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
/>
<link rel="icon" href="/favicon.ico" />
<title>{title}</title>
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
{jsonLd && <script is:inline type="application/ld+json" set:html={jsonLd}></script>}
<slot name="head" />
<style is:inline>
@@ -467,8 +467,6 @@ const i18nPayload = JSON.stringify({ locale, messages });
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
media="print"
onload="this.media='all'"
/>
<noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
@@ -478,8 +476,6 @@ const i18nPayload = JSON.stringify({ locale, messages });
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
>
<noscript>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -505,7 +501,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
<Header siteSettings={siteSettings} />
<main class="flex-1 w-full">
<main class="flex-1 w-full pb-[calc(5.5rem+env(safe-area-inset-bottom))] lg:pb-0">
<slot />
</main>

View File

@@ -8,27 +8,29 @@ import type {
PopularPostHighlight,
SiteSettings,
Tag as UiTag,
} from '../types';
} from "../types";
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
const PROD_DEFAULT_API_PORT = '5150';
const DEV_API_BASE_URL = "http://127.0.0.1:5150/api";
const PROD_DEFAULT_API_PORT = "5150";
function normalizeApiBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? '';
return value?.trim().replace(/\/$/, "") ?? "";
}
function getRuntimeEnv(
name:
| 'PUBLIC_API_BASE_URL'
| 'INTERNAL_API_BASE_URL'
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
| "PUBLIC_API_BASE_URL"
| "INTERNAL_API_BASE_URL"
| "PUBLIC_COMMENT_TURNSTILE_SITE_KEY"
| "PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY",
) {
const runtimeProcess = (globalThis as typeof globalThis & {
const runtimeProcess = (
globalThis as typeof globalThis & {
process?: {
env?: Record<string, string | undefined>;
};
}).process;
}
).process;
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
}
@@ -41,28 +43,30 @@ function normalizeVerificationMode(
value: string | null | undefined,
fallback: HumanVerificationMode,
): HumanVerificationMode {
switch ((value ?? '').trim().toLowerCase()) {
case 'off':
return 'off';
case 'captcha':
case 'normal':
case 'simple':
return 'captcha';
case 'turnstile':
return 'turnstile';
switch ((value ?? "").trim().toLowerCase()) {
case "off":
return "off";
case "captcha":
case "normal":
case "simple":
return "captcha";
case "turnstile":
return "turnstile";
default:
return fallback;
}
}
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(
import.meta.env.PUBLIC_API_BASE_URL,
);
const buildTimeCommentTurnstileSiteKey =
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? "";
const buildTimeWebPushVapidPublicKey =
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? "";
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
const runtimePublicApiBaseUrl = getRuntimeEnv("PUBLIC_API_BASE_URL");
if (runtimePublicApiBaseUrl) {
return runtimePublicApiBaseUrl;
}
@@ -84,7 +88,7 @@ export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
}
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
const runtimeInternalApiBaseUrl = getRuntimeEnv("INTERNAL_API_BASE_URL");
if (runtimeInternalApiBaseUrl) {
return runtimeInternalApiBaseUrl;
}
@@ -94,13 +98,15 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
export function resolvePublicCommentTurnstileSiteKey() {
return (
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
getRuntimeEnv("PUBLIC_COMMENT_TURNSTILE_SITE_KEY") ||
buildTimeCommentTurnstileSiteKey
);
}
export function resolvePublicWebPushVapidPublicKey() {
return (
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
getRuntimeEnv("PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY") ||
buildTimeWebPushVapidPublicKey
);
}
@@ -114,12 +120,12 @@ export interface ApiPost {
content: string;
category: string;
tags: string[];
post_type: 'article' | 'tweet';
post_type: "article" | "tweet";
image: string | null;
images: string[] | null;
pinned: boolean;
status: string | null;
visibility: 'public' | 'unlisted' | 'private' | null;
visibility: "public" | "unlisted" | "private" | null;
publish_at: string | null;
unpublish_at: string | null;
canonical_url: string | null;
@@ -144,7 +150,7 @@ export interface Comment {
content: string | null;
reply_to: string | null;
reply_to_comment_id: number | null;
scope: 'article' | 'paragraph';
scope: "article" | "paragraph";
paragraph_key: string | null;
paragraph_excerpt: string | null;
approved: boolean | null;
@@ -157,7 +163,7 @@ export interface CreateCommentInput {
nickname: string;
email?: string;
content: string;
scope?: 'article' | 'paragraph';
scope?: "article" | "paragraph";
paragraphKey?: string;
paragraphExcerpt?: string;
replyTo?: string | null;
@@ -210,7 +216,7 @@ export interface ApiFriendLink {
avatar_url: string | null;
description: string | null;
category: string | null;
status: 'pending' | 'approved' | 'rejected';
status: "pending" | "approved" | "rejected";
created_at: string;
updated_at: string;
}
@@ -300,7 +306,7 @@ export interface ApiSiteSettings {
}
export interface ContentAnalyticsInput {
eventType: 'page_view' | 'read_progress' | 'read_complete';
eventType: "page_view" | "read_progress" | "read_complete";
path: string;
postSlug?: string;
sessionId?: string;
@@ -379,7 +385,7 @@ export interface ApiSearchResult {
content: string | null;
category: string | null;
tags: string[] | null;
post_type: 'article' | 'tweet' | null;
post_type: "article" | "tweet" | null;
image: string | null;
pinned: boolean | null;
created_at: string;
@@ -404,10 +410,10 @@ export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult
export interface Review {
id: number;
title: string;
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
review_type: "game" | "anime" | "music" | "book" | "movie";
rating: number;
review_date: string;
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped';
status: "published" | "draft" | "completed" | "in-progress" | "dropped";
description: string;
tags: string;
cover: string;
@@ -417,59 +423,60 @@ export interface Review {
}
export type AppFriendLink = UiFriendLink & {
status: ApiFriendLink['status'];
status: ApiFriendLink["status"];
};
export const DEFAULT_SITE_SETTINGS: SiteSettings = {
id: '1',
siteName: 'InitCool',
siteShortName: 'Termi',
siteUrl: 'https://init.cool',
siteTitle: 'InitCool · 技术笔记与内容档案',
siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。',
heroTitle: '欢迎来到 InitCool',
heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。',
ownerName: 'InitCool',
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
location: 'Hong Kong',
id: "1",
siteName: "InitCool",
siteShortName: "Termi",
siteUrl: "https://init.cool",
siteTitle: "InitCool · 技术笔记与内容档案",
siteDescription: "一个认真折腾、偶尔整活的小站。",
heroTitle: "欢迎光临,先随便翻翻",
heroSubtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
ownerName: "InitCool",
ownerTitle: "负责把脑洞拧成页面的人",
ownerBio:
"一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。",
location: "Hong Kong",
social: {
github: 'https://github.com/limitcool',
twitter: '',
email: 'mailto:initcoool@gmail.com',
github: "https://github.com/limitcool",
twitter: "",
email: "mailto:initcoool@gmail.com",
},
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
techStack: ["Rust", "Go", "Python", "Svelte", "Astro", "Loco.rs"],
musicEnabled: true,
musicPlaylist: [
{
title: '山中来信',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
title: "山中来信",
artist: "InitCool Radio",
album: "站点默认歌单",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
coverImageUrl:
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80',
accentColor: '#2f6b5f',
description: '适合文章阅读时循环播放的轻氛围曲。',
"https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80",
accentColor: "#2f6b5f",
description: "适合文章阅读时循环播放的轻氛围曲。",
},
{
title: '风吹松声',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
title: "风吹松声",
artist: "InitCool Radio",
album: "站点默认歌单",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
coverImageUrl:
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80',
accentColor: '#8a5b35',
description: '偏木质感的器乐氛围,适合深夜浏览。',
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80",
accentColor: "#8a5b35",
description: "偏木质感的器乐氛围,适合深夜浏览。",
},
{
title: '夜航小记',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
title: "夜航小记",
artist: "InitCool Radio",
album: "站点默认歌单",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
coverImageUrl:
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80',
accentColor: '#375a7f',
description: '节奏更明显一点,适合切换阅读状态。',
"https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80",
accentColor: "#375a7f",
description: "节奏更明显一点,适合切换阅读状态。",
},
],
ai: {
@@ -477,16 +484,17 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
},
comments: {
paragraphsEnabled: true,
verificationMode: 'captcha',
verificationMode: "captcha",
turnstileEnabled: false,
turnstileSiteKey: undefined,
},
subscriptions: {
popupEnabled: true,
popupTitle: '订阅更新',
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
popupTitle: "订阅更新",
popupDescription:
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。",
popupDelaySeconds: 18,
verificationMode: 'off',
verificationMode: "off",
turnstileEnabled: false,
turnstileSiteKey: undefined,
webPushEnabled: false,
@@ -503,7 +511,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
const estimateReadTime = (content: string | null | undefined) => {
const text = content?.trim() || '';
const text = content?.trim() || "";
const minutes = Math.max(1, Math.ceil(text.length / 300));
return `${minutes} 分钟`;
};
@@ -567,12 +575,12 @@ const normalizeAvatarUrl = (value: string | null | undefined) => {
try {
const host = new URL(value).hostname.toLowerCase();
const isReservedExampleHost =
host === 'example.com' ||
host === 'example.org' ||
host === 'example.net' ||
host.endsWith('.example.com') ||
host.endsWith('.example.org') ||
host.endsWith('.example.net');
host === "example.com" ||
host === "example.org" ||
host === "example.net" ||
host.endsWith(".example.com") ||
host.endsWith(".example.org") ||
host.endsWith(".example.net");
return isReservedExampleHost ? undefined : value;
} catch {
@@ -595,15 +603,16 @@ const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
const commentVerificationMode = normalizeVerificationMode(
settings.comment_verification_mode,
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha',
settings.comment_turnstile_enabled ? "turnstile" : "captcha",
);
const subscriptionVerificationMode = normalizeVerificationMode(
settings.subscription_verification_mode,
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
settings.subscription_turnstile_enabled ? "turnstile" : "off",
);
const musicEnabled = settings.music_enabled ?? true;
const normalizedMusicPlaylist =
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
const normalizedMusicPlaylist = settings.music_playlist?.filter(
(item) => item?.title?.trim() && item?.url?.trim(),
)?.length
? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim())
.map((item) => ({
@@ -620,10 +629,12 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
return {
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteShortName:
settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
siteDescription:
settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
@@ -636,7 +647,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
techStack: settings.tech_stack?.length
? settings.tech_stack
: DEFAULT_SITE_SETTINGS.techStack,
musicEnabled,
musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
ai: {
@@ -645,15 +658,19 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
comments: {
verificationMode: commentVerificationMode,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: commentVerificationMode === 'turnstile',
turnstileEnabled: commentVerificationMode === "turnstile",
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
settings.turnstile_site_key ||
resolvePublicCommentTurnstileSiteKey() ||
undefined,
},
subscriptions: {
popupEnabled:
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
settings.subscription_popup_enabled ??
DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
settings.subscription_popup_title ||
DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription:
settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
@@ -661,9 +678,11 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
turnstileEnabled: subscriptionVerificationMode === "turnstile",
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
settings.turnstile_site_key ||
resolvePublicCommentTurnstileSiteKey() ||
undefined,
webPushEnabled: Boolean(settings.web_push_enabled),
webPushVapidPublicKey:
settings.web_push_vapid_public_key ||
@@ -680,7 +699,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
};
const normalizeContentOverview = (
overview: ApiHomePagePayload['content_overview'] | undefined,
overview: ApiHomePagePayload["content_overview"] | undefined,
): ContentOverview => ({
totalPageViews: overview?.total_page_views ?? 0,
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
@@ -692,9 +711,9 @@ const normalizeContentOverview = (
});
const CONTENT_WINDOW_META = [
{ key: '24h', label: '24h', days: 1 },
{ key: '7d', label: '7d', days: 7 },
{ key: '30d', label: '30d', days: 30 },
{ key: "24h", label: "24h", days: 1 },
{ key: "7d", label: "7d", days: 7 },
{ key: "30d", label: "30d", days: 30 },
] as const;
const normalizePopularPost = (
@@ -718,9 +737,9 @@ const normalizePopularPost = (
});
const normalizeContentRanges = (
ranges: ApiHomePagePayload['content_ranges'] | undefined,
overview: ApiHomePagePayload['content_overview'] | undefined,
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
ranges: ApiHomePagePayload["content_ranges"] | undefined,
overview: ApiHomePagePayload["content_overview"] | undefined,
popularPosts: ApiHomePagePayload["popular_posts"] | undefined,
postsBySlug: Map<string, UiPost>,
): ContentWindowHighlight[] => {
const normalizedRanges = new Map(
@@ -749,7 +768,7 @@ const normalizeContentRanges = (
return existing;
}
if (meta.key === '7d') {
if (meta.key === "7d") {
return {
key: meta.key,
label: meta.label,
@@ -758,13 +777,16 @@ const normalizeContentRanges = (
pageViews: overview?.page_views_last_7d ?? 0,
readCompletes: overview?.read_completes_last_7d ?? 0,
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
avgReadDurationMs:
overview?.avg_read_duration_ms_last_7d ?? undefined,
},
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
popularPosts: (popularPosts ?? []).map((item) =>
normalizePopularPost(item, postsBySlug),
),
};
}
if (meta.key === '24h') {
if (meta.key === "24h") {
return {
key: meta.key,
label: meta.label,
@@ -805,14 +827,16 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
const errorText = await response.text().catch(() => "");
throw new Error(
errorText || `API error: ${response.status} ${response.statusText}`,
);
}
if (response.status === 204) {
@@ -823,7 +847,7 @@ class ApiClient {
}
async getRawPosts(): Promise<ApiPost[]> {
return this.fetch<ApiPost[]>('/posts');
return this.fetch<ApiPost[]>("/posts");
}
async getPosts(): Promise<UiPost[]> {
@@ -850,16 +874,18 @@ class ApiClient {
sortOrder: string;
}> {
const params = new URLSearchParams();
if (options?.page) params.set('page', String(options.page));
if (options?.pageSize) params.set('page_size', String(options.pageSize));
if (options?.search) params.set('search', options.search);
if (options?.category) params.set('category', options.category);
if (options?.tag) params.set('tag', options.tag);
if (options?.postType) params.set('type', options.postType);
if (options?.sortBy) params.set('sort_by', options.sortBy);
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
if (options?.page) params.set("page", String(options.page));
if (options?.pageSize) params.set("page_size", String(options.pageSize));
if (options?.search) params.set("search", options.search);
if (options?.category) params.set("category", options.category);
if (options?.tag) params.set("tag", options.tag);
if (options?.postType) params.set("type", options.postType);
if (options?.sortBy) params.set("sort_by", options.sortBy);
if (options?.sortOrder) params.set("sort_order", options.sortOrder);
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(
`/posts/page?${params.toString()}`,
);
return {
items: payload.items.map(normalizePost),
page: payload.page,
@@ -877,27 +903,32 @@ class ApiClient {
}
async getPostBySlug(slug: string): Promise<UiPost | null> {
const response = await fetch(`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`, {
const response = await fetch(
`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`,
{
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
const errorText = await response.text().catch(() => "");
throw new Error(
errorText || `API error: ${response.status} ${response.statusText}`,
);
}
return normalizePost((await response.json()) as ApiPost);
}
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
await this.fetch<{ recorded: boolean }>('/analytics/content', {
method: 'POST',
await this.fetch<{ recorded: boolean }>("/analytics/content", {
method: "POST",
body: JSON.stringify({
event_type: input.eventType,
path: input.path,
@@ -908,38 +939,42 @@ class ApiClient {
metadata: input.metadata,
referrer: input.referrer,
}),
})
});
}
async getComments(
postSlug: string,
options?: {
approved?: boolean;
scope?: 'article' | 'paragraph';
scope?: "article" | "paragraph";
paragraphKey?: string;
}
},
): Promise<Comment[]> {
const params = new URLSearchParams({ post_slug: postSlug });
if (options?.approved !== undefined) {
params.set('approved', String(options.approved));
params.set("approved", String(options.approved));
}
if (options?.scope) {
params.set('scope', options.scope);
params.set("scope", options.scope);
}
if (options?.paragraphKey) {
params.set('paragraph_key', options.paragraphKey);
params.set("paragraph_key", options.paragraphKey);
}
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
}
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
async getParagraphCommentSummary(
postSlug: string,
): Promise<ParagraphCommentSummary[]> {
const params = new URLSearchParams({ post_slug: postSlug });
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
return this.fetch<ParagraphCommentSummary[]>(
`/comments/paragraphs/summary?${params.toString()}`,
);
}
async createComment(comment: CreateCommentInput): Promise<Comment> {
return this.fetch<Comment>('/comments', {
method: 'POST',
return this.fetch<Comment>("/comments", {
method: "POST",
body: JSON.stringify({
postSlug: comment.postSlug,
nickname: comment.nickname,
@@ -959,11 +994,11 @@ class ApiClient {
}
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
return this.fetch<CommentCaptchaChallenge>("/comments/captcha");
}
async getReviews(): Promise<Review[]> {
return this.fetch<Review[]>('/reviews');
return this.fetch<Review[]>("/reviews");
}
async getReview(id: number): Promise<Review> {
@@ -971,7 +1006,7 @@ class ApiClient {
}
async getRawFriendLinks(): Promise<ApiFriendLink[]> {
return this.fetch<ApiFriendLink[]>('/friend_links');
return this.fetch<ApiFriendLink[]>("/friend_links");
}
async getFriendLinks(): Promise<AppFriendLink[]> {
@@ -979,9 +1014,11 @@ class ApiClient {
return friendLinks.map(normalizeFriendLink);
}
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> {
return this.fetch<ApiFriendLink>('/friend_links', {
method: 'POST',
async createFriendLink(
friendLink: CreateFriendLinkInput,
): Promise<ApiFriendLink> {
return this.fetch<ApiFriendLink>("/friend_links", {
method: "POST",
body: JSON.stringify(friendLink),
});
}
@@ -994,8 +1031,8 @@ class ApiClient {
captchaToken?: string;
captchaAnswer?: string;
}): Promise<PublicSubscriptionResponse> {
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
method: 'POST',
return this.fetch<PublicSubscriptionResponse>("/subscriptions", {
method: "POST",
body: JSON.stringify({
email: input.email,
displayName: input.displayName,
@@ -1007,14 +1044,21 @@ class ApiClient {
});
}
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
method: 'POST',
async confirmSubscription(
token: string,
): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
"/subscriptions/confirm",
{
method: "POST",
body: JSON.stringify({ token }),
});
},
);
}
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
async getManagedSubscription(
token: string,
): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
);
@@ -1026,26 +1070,34 @@ class ApiClient {
status?: string | null;
filters?: Record<string, unknown> | null;
}): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
method: 'PATCH',
return this.fetch<PublicSubscriptionManageResponse>(
"/subscriptions/manage",
{
method: "PATCH",
body: JSON.stringify({
token: input.token,
displayName: input.displayName,
status: input.status,
filters: input.filters,
}),
});
},
);
}
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
method: 'POST',
async unsubscribeSubscription(
token: string,
): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
"/subscriptions/unsubscribe",
{
method: "POST",
body: JSON.stringify({ token }),
});
},
);
}
async getRawTags(): Promise<ApiTag[]> {
return this.fetch<ApiTag[]>('/tags');
return this.fetch<ApiTag[]>("/tags");
}
async getTags(): Promise<UiTag[]> {
@@ -1054,7 +1106,7 @@ class ApiClient {
}
async getRawSiteSettings(): Promise<ApiSiteSettings> {
return this.fetch<ApiSiteSettings>('/site_settings');
return this.fetch<ApiSiteSettings>("/site_settings");
}
async getSiteSettings(): Promise<SiteSettings> {
@@ -1072,7 +1124,7 @@ class ApiClient {
contentRanges: ContentWindowHighlight[];
popularPosts: PopularPostHighlight[];
}> {
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
const payload = await this.fetch<ApiHomePagePayload>("/site_settings/home");
const posts = (payload.posts ?? []).map(normalizePost);
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
const popularPosts = (payload.popular_posts ?? []).map((item) =>
@@ -1097,20 +1149,22 @@ class ApiClient {
}
async getCategories(): Promise<UiCategory[]> {
const categories = await this.fetch<ApiCategory[]>('/categories');
const categories = await this.fetch<ApiCategory[]>("/categories");
return categories.map(normalizeCategory);
}
async getPostsByCategory(category: string): Promise<UiPost[]> {
const posts = await this.getPosts();
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase());
return posts.filter(
(post) => post.category?.toLowerCase() === category.toLowerCase(),
);
}
async getPostsByTag(tag: string): Promise<UiPost[]> {
const posts = await this.getPosts();
const normalizedTag = normalizeTagToken(tag);
return posts.filter(post =>
post.tags?.some(item => normalizeTagToken(item) === normalizedTag)
return posts.filter((post) =>
post.tags?.some((item) => normalizeTagToken(item) === normalizedTag),
);
}
@@ -1119,18 +1173,20 @@ class ApiClient {
q: query,
limit: String(limit),
});
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`);
const results = await this.fetch<ApiSearchResult[]>(
`/search?${params.toString()}`,
);
return results.map(result =>
return results.map((result) =>
normalizePost({
id: result.id,
title: result.title || 'Untitled',
title: result.title || "Untitled",
slug: result.slug,
description: result.description || '',
content: result.content || '',
category: result.category || '',
description: result.description || "",
content: result.content || "",
category: result.category || "",
tags: result.tags ?? [],
post_type: result.post_type || 'article',
post_type: result.post_type || "article",
image: result.image,
images: null,
pinned: result.pinned ?? false,
@@ -1145,7 +1201,7 @@ class ApiClient {
redirect_to: null,
created_at: result.created_at,
updated_at: result.updated_at,
})
}),
);
}
@@ -1169,27 +1225,29 @@ class ApiClient {
sortOrder: string;
}> {
const params = new URLSearchParams({ q: options.query });
if (options.page) params.set('page', String(options.page));
if (options.pageSize) params.set('page_size', String(options.pageSize));
if (options.category) params.set('category', options.category);
if (options.tag) params.set('tag', options.tag);
if (options.postType) params.set('type', options.postType);
if (options.sortBy) params.set('sort_by', options.sortBy);
if (options.sortOrder) params.set('sort_order', options.sortOrder);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.category) params.set("category", options.category);
if (options.tag) params.set("tag", options.tag);
if (options.postType) params.set("type", options.postType);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortOrder) params.set("sort_order", options.sortOrder);
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
const payload = await this.fetch<ApiPagedSearchResponse>(
`/search/page?${params.toString()}`,
);
return {
query: payload.query,
items: payload.items.map((result) =>
normalizePost({
id: result.id,
title: result.title || 'Untitled',
title: result.title || "Untitled",
slug: result.slug,
description: result.description || '',
content: result.content || '',
category: result.category || '',
description: result.description || "",
content: result.content || "",
category: result.category || "",
tags: result.tags ?? [],
post_type: result.post_type || 'article',
post_type: result.post_type || "article",
image: result.image,
images: null,
pinned: result.pinned ?? false,
@@ -1216,15 +1274,20 @@ class ApiClient {
}
async askAi(question: string): Promise<AiAskResponse> {
return this.fetch<AiAskResponse>('/ai/ask', {
method: 'POST',
return this.fetch<AiAskResponse>("/ai/ask", {
method: "POST",
body: JSON.stringify({ question }),
});
}
}
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
export function createApiClient(options?: {
baseUrl?: string;
requestUrl?: string | URL;
}) {
return new ApiClient(
options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl),
);
}
export const api = createApiClient();

View File

@@ -40,7 +40,7 @@ export interface TerminalConfig {
description: string;
date: string;
readTime: string;
type: 'article' | 'tweet';
type: "article" | "tweet";
tags: string[];
link: string;
};
@@ -83,14 +83,14 @@ export interface TerminalConfig {
}
export const terminalConfig: TerminalConfig = {
defaultCategory: 'blog',
welcomeMessage: '欢迎来到 InitCool',
defaultCategory: "blog",
welcomeMessage: "欢迎来到 InitCool",
prompt: {
prefix: 'user@blog',
separator: ':',
path: '~/',
suffix: '$',
mobile: '~$'
prefix: "user@blog",
separator: ":",
path: "~/",
suffix: "$",
mobile: "~$",
},
asciiArt: `
I N N I TTTTT CCCC OOO OOO L
@@ -98,70 +98,70 @@ I NN N I T C O O O O L
I N N N I T C O O O O L
I N NN I T C O O O O L
I N N I T CCCC OOO OOO LLLLL`,
title: '~/blog',
title: "~/blog",
welcome: {
title: '欢迎来到 InitCool',
subtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。'
title: "欢迎来到 InitCool",
subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
},
navLinks: [
{ icon: 'fa-file-code', text: '文章', href: '/articles' },
{ icon: 'fa-folder', text: '分类', href: '/categories' },
{ icon: 'fa-tags', text: '标签', href: '/tags' },
{ icon: 'fa-stream', text: '时间轴', href: '/timeline' },
{ icon: 'fa-star', text: '评价', href: '/reviews' },
{ icon: 'fa-link', text: '友链', href: '/friends' },
{ icon: 'fa-user-secret', text: '关于', href: '/about' }
{ icon: "fa-file-code", text: "文章", href: "/articles" },
{ icon: "fa-folder", text: "分类", href: "/categories" },
{ icon: "fa-tags", text: "标签", href: "/tags" },
{ icon: "fa-stream", text: "时间轴", href: "/timeline" },
{ icon: "fa-star", text: "评价", href: "/reviews" },
{ icon: "fa-link", text: "友链", href: "/friends" },
{ icon: "fa-user-secret", text: "关于", href: "/about" },
],
categories: {
blog: {
title: '博客',
description: '我的个人博客文章',
title: "博客",
description: "我的个人博客文章",
items: [
{
command: 'help',
description: '显示帮助信息',
shortDesc: '显示帮助信息'
}
]
}
command: "help",
description: "显示帮助信息",
shortDesc: "显示帮助信息",
},
],
},
},
postTypes: {
article: { color: '#00ff9d', label: '博客文章' },
tweet: { color: '#00b8ff', label: '推文' }
article: { color: "#00ff9d", label: "博客文章" },
tweet: { color: "#00b8ff", label: "推文" },
},
socialLinks: {
github: '',
twitter: '',
email: ''
github: "",
twitter: "",
email: "",
},
tools: [
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' },
{ icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' }
{ icon: "fa-sitemap", href: "/sitemap.xml", title: "站点地图" },
{ icon: "fa-rss", href: "/rss.xml", title: "RSS订阅" },
],
search: {
placeholders: {
default: "'关键词' 文章 / 标签 / 分类",
small: "搜索...",
medium: "搜索文章..."
medium: "搜索文章...",
},
promptText: "搜索",
emptyResultText: "输入关键词搜索文章"
emptyResultText: "输入关键词搜索文章",
},
terminal: {
defaultWindowTitle: 'user@terminal: ~/blog',
defaultWindowTitle: "user@terminal: ~/blog",
controls: {
colors: {
close: '#ff5f56',
minimize: '#ffbd2e',
expand: '#27c93f'
}
close: "#ff5f56",
minimize: "#ffbd2e",
expand: "#27c93f",
},
},
animation: {
glowDuration: '4s'
}
glowDuration: "4s",
},
},
branding: {
name: 'InitCool',
shortName: 'Termi'
}
name: "InitCool",
shortName: "Termi",
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,8 @@ export interface DiscoveryFaqOptions {
signals?: string[];
}
export type JsonLdObject = Record<string, unknown>;
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
@@ -280,6 +282,12 @@ export function buildFaqJsonLd(faqs: ArticleFaqItem[]) {
};
}
export function compactJsonLd<T extends JsonLdObject>(
items: Array<T | null | undefined | false>,
): T[] {
return items.filter((item): item is T => Boolean(item));
}
export function buildPostItemList(posts: Post[], siteUrl: string) {
return posts.map((post, index) => ({
'@type': 'ListItem',

View File

@@ -1,4 +1,4 @@
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
const SERVICE_WORKER_URL = "/termi-web-push-sw.js";
export type BrowserPushSubscriptionPayload = {
endpoint: string;
@@ -9,21 +9,26 @@ export type BrowserPushSubscriptionPayload = {
};
};
export type BrowserPushSubscriptionState = {
subscription: PushSubscription | null;
stale: boolean;
};
function ensureBrowserSupport() {
if (
typeof window === 'undefined' ||
!('Notification' in window) ||
!('serviceWorker' in navigator) ||
!('PushManager' in window)
typeof window === "undefined" ||
!("Notification" in window) ||
!("serviceWorker" in navigator) ||
!("PushManager" in window)
) {
throw new Error('当前浏览器不支持 Web Push。');
throw new Error("当前浏览器不支持 Web Push。");
}
}
function urlBase64ToUint8Array(base64String: string) {
const normalized = base64String.trim();
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
const base64 = (normalized + padding).replace(/-/g, "+").replace(/_/g, "/");
const binary = window.atob(base64);
const output = new Uint8Array(binary.length);
@@ -34,22 +39,66 @@ function urlBase64ToUint8Array(base64String: string) {
return output;
}
function toUint8Array(value: BufferSource | null | undefined) {
if (!value) {
return null;
}
if (value instanceof Uint8Array) {
return value;
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
function uint8ArraysEqual(left: Uint8Array | null, right: Uint8Array | null) {
if (!left || !right || left.byteLength !== right.byteLength) {
return false;
}
for (let index = 0; index < left.byteLength; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function subscriptionMatchesPublicKey(
subscription: PushSubscription,
publicKeyBytes: Uint8Array | null,
) {
if (!publicKeyBytes) {
return true;
}
const currentKey = toUint8Array(
subscription.options?.applicationServerKey ?? null,
);
return uint8ArraysEqual(currentKey, publicKeyBytes);
}
async function getRegistration() {
ensureBrowserSupport();
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" });
return navigator.serviceWorker.ready;
}
function normalizeSubscription(
subscription: PushSubscription | PushSubscriptionJSON,
): BrowserPushSubscriptionPayload {
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
const endpoint = json.endpoint?.trim() || '';
const auth = json.keys?.auth?.trim() || '';
const p256dh = json.keys?.p256dh?.trim() || '';
const json = "toJSON" in subscription ? subscription.toJSON() : subscription;
const endpoint = json.endpoint?.trim() || "";
const auth = json.keys?.auth?.trim() || "";
const p256dh = json.keys?.p256dh?.trim() || "";
if (!endpoint || !auth || !p256dh) {
throw new Error('浏览器返回的 PushSubscription 不完整。');
throw new Error("浏览器返回的 PushSubscription 不完整。");
}
return {
@@ -64,20 +113,53 @@ function normalizeSubscription(
export function supportsBrowserPush() {
return (
typeof window !== 'undefined' &&
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
typeof window !== "undefined" &&
"Notification" in window &&
"serviceWorker" in navigator &&
"PushManager" in window
);
}
export async function getBrowserPushSubscription() {
export async function getBrowserPushSubscriptionState(
publicKey?: string,
): Promise<BrowserPushSubscriptionState> {
if (!supportsBrowserPush()) {
return null;
return {
subscription: null,
stale: false,
};
}
const registration = await getRegistration();
return registration.pushManager.getSubscription();
const subscription = await registration.pushManager.getSubscription();
const normalizedPublicKey = publicKey?.trim() || "";
const publicKeyBytes = normalizedPublicKey
? urlBase64ToUint8Array(normalizedPublicKey)
: null;
if (!subscription) {
return {
subscription: null,
stale: false,
};
}
if (!subscriptionMatchesPublicKey(subscription, publicKeyBytes)) {
return {
subscription: null,
stale: true,
};
}
return {
subscription,
stale: false,
};
}
export async function getBrowserPushSubscription(publicKey?: string) {
const state = await getBrowserPushSubscriptionState(publicKey);
return state.subscription;
}
export async function ensureBrowserPushSubscription(
@@ -86,25 +168,34 @@ export async function ensureBrowserPushSubscription(
ensureBrowserSupport();
if (!publicKey.trim()) {
throw new Error('Web Push 公钥未配置。');
throw new Error("Web Push 公钥未配置。");
}
const permission =
Notification.permission === 'granted'
? 'granted'
Notification.permission === "granted"
? "granted"
: await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('浏览器通知权限未开启。');
if (permission !== "granted") {
throw new Error("浏览器通知权限未开启。");
}
const registration = await getRegistration();
const publicKeyBytes = urlBase64ToUint8Array(publicKey);
let subscription = await registration.pushManager.getSubscription();
if (
subscription &&
!subscriptionMatchesPublicKey(subscription, publicKeyBytes)
) {
await subscription.unsubscribe().catch(() => undefined);
subscription = null;
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
applicationServerKey: publicKeyBytes,
});
}

View File

@@ -9,7 +9,7 @@ import StatsList from '../../components/StatsList.astro';
import TechStackList from '../../components/TechStackList.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
export const prerender = false;
@@ -130,7 +130,7 @@ const aboutJsonLd = [
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
description={siteSettings.siteDescription}
siteSettings={siteSettings}
jsonLd={aboutJsonLd.filter(Boolean)}
jsonLd={compactJsonLd(aboutJsonLd)}
>
<PageViewTracker pageType="about" entityId="about" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

View File

@@ -23,6 +23,7 @@ import {
buildArticleHighlights,
buildArticlePreviewParagraphs,
buildArticleSynopsis,
compactJsonLd,
resolvePostUpdatedAt,
} from '../../lib/seo';
import type { PopularPostHighlight } from '../../lib/types';
@@ -45,7 +46,6 @@ let post = null;
let siteSettings = DEFAULT_SITE_SETTINGS;
const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`;
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
let postLookupFailed = false;
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
apiClient.getPostBySlug(slug ?? ''),
@@ -56,7 +56,6 @@ const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettle
if (postResult.status === 'fulfilled') {
post = postResult.value;
} else {
postLookupFailed = true;
console.error('API Error:', postResult.reason);
}
@@ -77,8 +76,8 @@ if (homeDataResult.status === 'fulfilled') {
if (!post) {
return new Response(null, {
status: postLookupFailed ? 503 : 404,
headers: postLookupFailed ? { 'Retry-After': '120' } : undefined,
status: postResult.status !== 'fulfilled' ? 503 : 404,
headers: postResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
});
}
@@ -371,7 +370,7 @@ const breadcrumbJsonLd = {
ogImage={ogImage}
ogType="article"
twitterCard="summary_large_image"
jsonLd={[articleJsonLd, breadcrumbJsonLd, faqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([articleJsonLd, breadcrumbJsonLd, faqJsonLd])}
>
<Fragment slot="head">
<meta property="article:published_time" content={publishedAt} />
@@ -438,7 +437,7 @@ const breadcrumbJsonLd = {
</div>
<section class="grid items-start gap-5 xl:grid-cols-[minmax(0,1.62fr)_minmax(16.5rem,0.78fr)] 2xl:grid-cols-[minmax(0,1.8fr)_minmax(17rem,0.82fr)]">
<div class="relative overflow-hidden rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(var(--secondary-rgb),0.05)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6">
<div class="relative overflow-hidden rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)] p-5 shadow-[0_16px_40px_rgba(15,23,42,0.08)] sm:p-6">
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
@@ -459,7 +458,7 @@ const breadcrumbJsonLd = {
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
</div>
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
<div class="space-y-3">
{articlePreviewParagraphs.map((paragraph) => (
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
@@ -501,7 +500,7 @@ const breadcrumbJsonLd = {
</div>
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] 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)]">
@@ -548,7 +547,7 @@ const breadcrumbJsonLd = {
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
{digestStats.map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
</div>
@@ -559,7 +558,7 @@ const breadcrumbJsonLd = {
</div>
<div class="space-y-4">
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.sourceTitle}
</h3>
@@ -602,13 +601,13 @@ const breadcrumbJsonLd = {
</div>
{articleHighlights.length > 0 && (
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle}
</h3>
<div class="mt-4 space-y-3">
{articleHighlights.map((item, index) => (
<div class="flex items-start gap-3 rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
<div class="flex items-start gap-3 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_95%,transparent)] px-4 py-3">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
{index + 1}
</span>
@@ -829,7 +828,7 @@ const breadcrumbJsonLd = {
>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
@@ -838,7 +837,7 @@ const breadcrumbJsonLd = {
</button>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
@@ -848,7 +847,7 @@ const breadcrumbJsonLd = {
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
@@ -858,7 +857,7 @@ const breadcrumbJsonLd = {
)}
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
@@ -904,19 +903,19 @@ const breadcrumbJsonLd = {
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_94%,white_6%),color-mix(in_oklab,var(--header-bg)_92%,white_4%))] p-5 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(--header-bg)] p-5">
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical}
</div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle}
</div>

View File

@@ -9,7 +9,7 @@ import FilterPill from '../../components/ui/FilterPill.astro';
import PostCard from '../../components/PostCard.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList, compactJsonLd } from '../../lib/seo';
import type { Category, Post, Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
@@ -193,7 +193,7 @@ const buildArticlesUrl = ({
siteSettings={siteSettings}
canonical={canonicalUrl}
noindex={hasActiveFilters}
jsonLd={[...jsonLd, articleIndexFaqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([...jsonLd, articleIndexFaqJsonLd])}
>
<PageViewTracker pageType="articles" entityId={`articles-page-${currentPage}`} />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

View File

@@ -4,9 +4,10 @@ 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 TerminalWindow from '../../components/ui/TerminalWindow.astro';
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
export const prerender = false;
@@ -70,14 +71,16 @@ const sharePanelCopy = isEnglish
description:
'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
examples: 'Prompts',
ai: 'AI',
status: 'Status',
statusValue: aiEnabled ? 'Ready' : 'Idle',
}
: {
badge: '问答入口',
title: '分享问答页',
description: '把这个问答页分享给需要快速检索站内内容的人。',
title: '把问答入口甩出去',
description: '有人想少走弯路时,把这页递过去就行。',
examples: '示例问题',
ai: 'AI',
status: '状态',
statusValue: aiEnabled ? '随时开问' : '暂时休息',
};
const askHighlights = buildDiscoveryHighlights([
t('ask.subtitle'),
@@ -104,28 +107,41 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings}
jsonLd={[...askJsonLd, askFaqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])}
>
<PageViewTracker pageType="ask" entityId="ask" />
<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="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
<div>
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div>
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
</div>
<div class:list={[
'rounded-full border px-3 py-1 text-xs font-mono',
aiEnabled
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300'
]}>
{aiEnabled ? t('common.featureOn') : t('common.featureOff')}
<section class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8">
<TerminalWindow title="~/ask" class="w-full">
<div class="px-4 pb-2">
<div class="terminal-panel ml-4 mt-4 space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-sparkles"></i>
{t('ask.terminalLabel')}
</span>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-[var(--title-color)] sm:text-3xl">{t('ask.title')}</h1>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)] sm:text-base">
{t('ask.subtitle')}
</p>
</div>
</div>
<div class="px-5 pt-6">
<span class:list={[
'terminal-stat-pill px-3 py-1.5 text-xs font-mono',
aiEnabled
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
]}>
<i class:list={[
'fas',
aiEnabled ? 'fa-wave-square' : 'fa-triangle-exclamation'
]}></i>
{aiEnabled ? '已待命' : '休息中'}
</span>
</div>
<SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
@@ -136,13 +152,11 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') },
{ label: sharePanelCopy.status, value: sharePanelCopy.statusValue },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="px-5 pt-6">
<DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai"
@@ -151,20 +165,19 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
highlights={askHighlights}
faqs={askFaqs}
/>
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
<div class="min-w-0">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_18rem]">
<div class="min-w-0 space-y-5">
{aiEnabled ? (
<>
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<form id="ai-form" class="terminal-panel-muted space-y-4">
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea
id="ai-question"
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
class="min-h-[160px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
placeholder={t('ask.textareaPlaceholder')}
></textarea>
<div class="mt-4 flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span>
@@ -173,7 +186,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div>
</form>
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
<div id="ai-result" class="terminal-panel-muted hidden">
<div class="flex items-center justify-between gap-3">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div>
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
@@ -183,7 +196,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div>
</>
) : (
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
<div class="terminal-panel-muted border-dashed px-5 py-8">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.disabledStateLabel')}</div>
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
@@ -194,7 +207,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div>
<aside class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="terminal-panel-muted">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2">
{sampleQuestions.map((question) => (
@@ -209,7 +222,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="terminal-panel-muted">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.guide')}</div>
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
<li>{t('ask.guide1')}</li>
@@ -220,9 +233,255 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</aside>
</div>
</div>
</div>
</TerminalWindow>
</section>
</BaseLayout>
<style is:global>
.ask-connecting-shell {
display: inline-flex;
align-items: center;
gap: 0.95rem;
min-width: min(100%, 20rem);
border-radius: 1.1rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background:
radial-gradient(circle at top right, rgba(var(--primary-rgb), 0.11), transparent 28%),
radial-gradient(circle at bottom left, rgba(var(--primary-rgb), 0.05), transparent 34%),
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 98%, transparent),
color-mix(in oklab, var(--header-bg) 92%, transparent)
);
padding: 0.95rem 1.05rem;
box-shadow:
0 14px 28px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 0 1px rgba(var(--primary-rgb), 0.03);
}
.ask-gemini-avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
flex: 0 0 2.2rem;
border-radius: 999px;
background:
radial-gradient(circle at 30% 30%, rgba(160, 211, 255, 0.24), transparent 55%),
radial-gradient(circle at 70% 70%, rgba(var(--primary-rgb), 0.2), transparent 48%),
color-mix(in oklab, var(--terminal-bg) 88%, rgba(var(--primary-rgb), 0.16));
animation: ask-gemini-avatar-breathe 3.2s ease-in-out infinite;
}
.ask-gemini-avatar::before,
.ask-gemini-avatar::after {
content: '';
position: absolute;
inset: -0.3rem;
border-radius: 999px;
pointer-events: none;
}
.ask-gemini-avatar::before {
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.24), transparent 72%);
filter: blur(9px);
animation: ask-gemini-halo 3.2s ease-in-out infinite;
}
.ask-gemini-avatar::after {
inset: 0.2rem;
border: 1px solid rgba(255, 255, 255, 0.08);
opacity: 0.8;
}
.ask-gemini-orbit {
position: absolute;
inset: -0.52rem;
border-radius: 999px;
animation: ask-gemini-orbit-spin 2.15s linear infinite;
pointer-events: none;
}
.ask-gemini-orbit-svg {
width: 100%;
height: 100%;
overflow: visible;
transform: rotate(-90deg);
filter:
drop-shadow(0 0 8px rgba(var(--primary-rgb), 0.22))
drop-shadow(0 0 16px rgba(79, 160, 255, 0.16));
}
.ask-gemini-orbit-ring {
fill: none;
stroke: url(#ask-gemini-orbit-gradient);
stroke-width: 2.3;
stroke-linecap: butt;
stroke-dasharray: 34 110;
stroke-dashoffset: 0;
opacity: 1;
animation:
ask-gemini-orbit-length 2.1s cubic-bezier(0.6, 0.08, 0.28, 0.96) infinite,
ask-gemini-orbit-glow 3.6s ease-in-out infinite;
}
.ask-gemini-svg {
position: relative;
z-index: 1;
width: 2rem;
height: 2rem;
overflow: visible;
}
.ask-gemini-star-outline {
opacity: 0.25;
}
.ask-gemini-sweep {
transform-box: fill-box;
transform-origin: center;
animation: ask-gemini-sweep 2.9s cubic-bezier(0.55, 0.08, 0.28, 0.98) infinite;
}
.ask-gemini-sweep-secondary {
animation-delay: 1.15s;
opacity: 0.68;
}
.ask-connecting-copy {
min-width: 0;
color: var(--text);
font-size: 0.94rem;
line-height: 1.5;
letter-spacing: 0.01em;
}
.ask-connecting-copy span {
display: block;
color: color-mix(in oklab, var(--title-color) 72%, var(--text-secondary));
text-wrap: balance;
}
@keyframes ask-gemini-avatar-breathe {
0%,
100% {
transform: translateY(0) scale(0.98);
}
50% {
transform: translateY(-0.02rem) scale(1.04);
}
}
@keyframes ask-gemini-halo {
0%,
100% {
opacity: 0.45;
transform: scale(0.9);
}
50% {
opacity: 0.9;
transform: scale(1.08);
}
}
@keyframes ask-gemini-orbit-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ask-gemini-orbit-glow {
0%,
100% {
opacity: 0.7;
filter: saturate(0.96);
}
50% {
opacity: 1;
filter: saturate(1.12);
}
}
@keyframes ask-gemini-orbit-length {
0% {
stroke-dasharray: 30 102;
stroke-dashoffset: 4;
opacity: 0.78;
}
38% {
stroke-dasharray: 64 68;
stroke-dashoffset: -14;
opacity: 0.92;
}
68% {
stroke-dasharray: 96 36;
stroke-dashoffset: -30;
opacity: 0.98;
}
88% {
stroke-dasharray: 118 14;
stroke-dashoffset: -48;
opacity: 1;
}
96% {
stroke-dasharray: 124 8;
stroke-dashoffset: -56;
opacity: 1;
}
100% {
stroke-dasharray: 30 102;
stroke-dashoffset: -64;
opacity: 0.78;
}
}
@keyframes ask-gemini-sweep {
0% {
opacity: 0;
transform: translate3d(-0.6rem, 0, 0) rotate(-34deg) scaleX(0.86);
}
18% {
opacity: 0.95;
}
56% {
opacity: 0.74;
}
100% {
opacity: 0;
transform: translate3d(0.7rem, 0, 0) rotate(-34deg) scaleX(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.ask-gemini-avatar,
.ask-gemini-avatar::before,
.ask-gemini-orbit,
.ask-gemini-sweep,
.ask-gemini-sweep-secondary {
animation: none;
}
}
</style>
{aiEnabled && (
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
const t = window.__termiTranslate;
@@ -317,6 +576,71 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
return html.join('');
}
function renderConnectingState(message) {
return `
<div class="ask-connecting-shell" role="status" aria-live="polite">
<span class="ask-gemini-avatar" aria-hidden="true">
<span class="ask-gemini-orbit">
<svg class="ask-gemini-orbit-svg" viewBox="0 0 48 48" focusable="false">
<defs>
<linearGradient id="ask-gemini-orbit-gradient" x1="5" y1="43" x2="43" y2="5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2563eb"></stop>
<stop offset="48%" stop-color="#22d3ee"></stop>
<stop offset="78%" stop-color="#fde68a"></stop>
<stop offset="100%" stop-color="#ffffff"></stop>
</linearGradient>
</defs>
<circle class="ask-gemini-orbit-ring" cx="24" cy="24" r="21"></circle>
</svg>
</span>
<svg class="ask-gemini-svg" viewBox="0 0 32 32" width="32" height="32" focusable="false">
<defs>
<linearGradient id="ask-gemini-base" x1="5" y1="26" x2="27" y2="6" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#346bf1"></stop>
<stop offset="22%" stop-color="#3279f8"></stop>
<stop offset="45%" stop-color="#3186ff"></stop>
<stop offset="72%" stop-color="#4093ff"></stop>
<stop offset="100%" stop-color="#4fa0ff"></stop>
</linearGradient>
<linearGradient id="ask-gemini-aurora" x1="7" y1="23" x2="25" y2="9" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"></stop>
<stop offset="42%" stop-color="#ffffff" stop-opacity="0.96"></stop>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
</linearGradient>
<radialGradient id="ask-gemini-core" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"></stop>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
</radialGradient>
<clipPath id="ask-gemini-star-clip">
<path d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"></path>
</clipPath>
</defs>
<circle cx="16" cy="16" r="11" fill="url(#ask-gemini-core)" opacity="0.22"></circle>
<path
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
fill="url(#ask-gemini-base)"
></path>
<g clip-path="url(#ask-gemini-star-clip)">
<rect class="ask-gemini-sweep" x="2" y="13.7" width="28" height="4.2" rx="2.1" fill="url(#ask-gemini-aurora)"></rect>
<rect class="ask-gemini-sweep ask-gemini-sweep-secondary" x="1" y="14.7" width="30" height="2.7" rx="1.35" fill="url(#ask-gemini-aurora)"></rect>
</g>
<path
class="ask-gemini-star-outline"
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
fill="none"
stroke="#ffffff"
stroke-opacity="0.55"
stroke-width="0.5"
></path>
</svg>
</span>
<span class="ask-connecting-copy">
<span>${escapeHtml(message || t('ask.connectingShort'))}</span>
</span>
</div>
`;
}
function setInteractiveState(isLoading) {
if (submit) {
submit.toggleAttribute('disabled', isLoading);
@@ -512,7 +836,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
setInteractiveState(true);
result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
answer.innerHTML = renderConnectingState(t('ask.connectingShort'));
sources.innerHTML = '';
meta.textContent = '';
updatePrompt(t('ask.promptSubmitting'));

View File

@@ -20,9 +20,7 @@ const isEnglish = locale.startsWith('en');
let categories: Category[] = [];
let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
let categoriesFailed = false;
try {
const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
api.getCategories(),
api.getPosts(),
@@ -32,7 +30,6 @@ try {
if (categoriesResult.status === 'fulfilled') {
categories = categoriesResult.value;
} else {
categoriesFailed = true;
console.error('Failed to fetch categories:', categoriesResult.reason);
}
@@ -45,9 +42,6 @@ try {
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch category detail data:', error);
}
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
const category =
@@ -59,8 +53,8 @@ const category =
if (!category) {
return new Response(null, {
status: categoriesFailed ? 503 : 404,
headers: categoriesFailed ? { 'Retry-After': '120' } : undefined,
status: categoriesResult.status !== 'fulfilled' ? 503 : 404,
headers: categoriesResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
});
}

View File

@@ -3,7 +3,7 @@ import type { APIRoute } from 'astro'
import { createApiClient, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
function resolveFaviconTarget(requestUrl: URL, configured: string | undefined) {
const fallbackTarget = '/favicon.svg'
const fallbackTarget = '/favicon-default.ico'
const candidate = configured?.trim()
if (!candidate) {

View File

@@ -9,7 +9,7 @@ import FriendLinkCard from '../../components/FriendLinkCard.astro';
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
import type { AppFriendLink } from '../../lib/api/client';
export const prerender = false;
@@ -120,7 +120,7 @@ const friendsFaqJsonLd = buildFaqJsonLd(friendsFaqs);
title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`}
description={t('friends.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings}
jsonLd={[...friendsJsonLd, friendsFaqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([...friendsJsonLd, friendsFaqJsonLd])}
>
<PageViewTracker pageType="friends" entityId="friends-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

View File

@@ -13,7 +13,7 @@ import TechStackList from '../components/TechStackList.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList, compactJsonLd } from '../lib/seo';
import type { AppFriendLink } from '../lib/api/client';
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
@@ -119,6 +119,11 @@ const systemStats = [
{ label: t('common.categories'), value: String(categories.length) },
{ label: t('common.friends'), value: String(friendLinks.length) },
];
const heroGlanceStats = [
{ label: t('common.posts'), value: String(allPosts.length), icon: 'fa-file-alt' },
{ label: t('common.categories'), value: String(categories.length), icon: 'fa-folder-open' },
{ label: t('common.tags'), value: String(tags.length), icon: 'fa-hashtag' },
];
const techStack = siteSettings.techStack.map(name => ({ name }));
const tagFrequency = new Map<string, number>();
@@ -153,8 +158,9 @@ const activeContentRange =
const popularRangeCards = contentRanges.flatMap((range) =>
range.popularPosts
.filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post))
.map((item) => ({
.map((item, index) => ({
rangeKey: range.key,
rank: index + 1,
item,
post: item.post,
})),
@@ -221,17 +227,6 @@ const discoverPrompt = hasActiveFilters
const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: t('home.promptPostsDefault', { count: previewCount });
const popularPrompt = t('home.promptPopularRange', { label: activeContentRange.label });
const navLinks = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(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 = [
{
@@ -257,14 +252,18 @@ const homeShareCopy = isEnglish
'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: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
badge: '入口页',
title: '把首页甩出去',
description: '不知道发什么时,先发这个入口。轻松、不剧透,还挺省心。',
};
const homeShareSummary = isEnglish
? 'A light entry point for curious visitors. Click around and let the rest reveal itself.'
: '这是一个适合顺手转发的小入口,先逛逛,细节留到点开再说。';
const homeSidebarCopy = isEnglish
? {
quickLinks: 'Quick links',
quickLinksDesc: 'Jump to the main sections of the site.',
quickLinksDesc: 'Keep the main channels pinned on the right so you can switch context without losing reading flow.',
quickLinksMore: 'More channels',
popularTitle: 'Hot now',
popularDesc: 'Track the most-read content in the selected window.',
friendsTitle: 'Friend links',
@@ -275,15 +274,39 @@ const homeSidebarCopy = isEnglish
}
: {
quickLinks: '快速入口',
quickLinksDesc: '常用入口收进侧栏,首页阅读流更清爽。',
quickLinksDesc: '常用入口都放这儿,手别忙,点就行。',
quickLinksMore: '更多频道',
popularTitle: '最近热门',
popularDesc: '按当前时间窗口查看最受关注的内容。',
popularDesc: '看看最近是谁在悄悄抢镜。',
friendsTitle: '友情链接',
friendsDesc: '先看几个常访问的站点入口。',
friendsDesc: '隔壁摊位也许也有好东西。',
statsTitle: '站点概览',
statsDesc: '快速看一下当前站点规模与内容状态。',
aiBriefTitle: '站点摘要',
statsDesc: '轻量围观一下站内气氛,不必知道太多。',
aiBriefTitle: '站点便签',
};
const homeAboutSectionCopy = isEnglish
? {
techDesc: 'The main tools and frameworks currently carrying the site and its content flow.',
metricsDesc: 'Site scale and basic signals, shown with the same terminal panel language as the rest of the page.',
apiAlert: 'home / api alert',
}
: {
techDesc: '当前页面和内容背后常用的技术组合。',
metricsDesc: '用同一套终端面板看站内规模,不再突然切回普通信息块。',
apiAlert: 'home / api alert',
};
const primaryQuickLinks = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const secondaryQuickLinks = [
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
];
const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription,
siteSettings.heroSubtitle,
@@ -314,7 +337,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
siteSettings={siteSettings}
canonical="/"
noindex={hasActiveFilters}
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([...homeJsonLd, homeFaqJsonLd])}
>
<PageViewTracker pageType="home" entityId="homepage" />
<div class="mx-auto max-w-[1480px] px-4 py-6 sm:px-6 lg:px-8">
@@ -325,10 +348,28 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<div class="mb-6 px-4">
<CommandPrompt command={t('home.promptWelcome')} />
<div class="ml-4 home-hero-shell">
<div class="min-w-0">
<div class="ml-4 home-hero-shell home-hero-shell--panel">
<div class="home-hero-copy">
<span class="terminal-kicker w-fit">
<i class="fas fa-terminal"></i>
home / overview
</span>
<p class="mb-1 text-lg font-bold text-[var(--primary)]">{siteSettings.heroTitle}</p>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
<div class="home-hero-glance">
{heroGlanceStats.map((item) => (
<div class="home-hero-glance-item">
<span class="home-hero-glance-icon">
<i class={`fas ${item.icon} text-[11px]`}></i>
</span>
<div class="min-w-0">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
<div class="text-sm font-semibold text-[var(--title-color)]">{item.value}</div>
</div>
</div>
))}
</div>
</div>
<div class="home-hero-meta">
@@ -355,13 +396,19 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
{apiError && (
<div class="mb-8 px-4">
<div class="ml-4 p-4 rounded-lg border border-[var(--danger)]/20 bg-[var(--danger)]/10 text-[var(--danger)] text-sm">
{apiError}
<div class="terminal-panel-muted ml-4 flex items-start gap-3 border-[color:color-mix(in_oklab,var(--danger)_24%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--danger)_8%,var(--terminal-bg)),color-mix(in_oklab,var(--danger)_4%,var(--header-bg)))] px-4 py-4 text-sm text-[var(--danger)]">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--danger)_24%,var(--border-color))] bg-[color:color-mix(in_oklab,var(--danger)_12%,var(--terminal-bg))]">
<i class="fas fa-triangle-exclamation"></i>
</span>
<div class="min-w-0 space-y-1">
<p class="text-[11px] font-mono uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{homeAboutSectionCopy.apiAlert}</p>
<p class="leading-7 text-[var(--danger)]">{apiError}</p>
</div>
</div>
</div>
)}
<div class="grid gap-6 px-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div class="grid gap-6 px-4 xl:grid-cols-[minmax(0,1fr)_320px] 2xl:grid-cols-[minmax(0,1fr)_340px]">
<div class="min-w-0 space-y-6">
<div id="discover">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
@@ -586,22 +633,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
</div>
<aside class="space-y-4 xl:sticky xl:top-24 xl:self-start">
<section class="terminal-panel space-y-4">
<aside class="home-sidebar-stack">
<section class="terminal-panel home-sidebar-card home-sidebar-card--quickmenu space-y-4 xl:sticky xl:top-24">
<div class="space-y-1">
<span class="terminal-kicker w-fit">
<i class="fas fa-compass"></i>
quick menu
</span>
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinks}</h3>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.quickLinksDesc}</p>
</div>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
{navLinks.map(link => (
<div class="home-sidebar-grid">
{primaryQuickLinks.map(link => (
<a
href={link.href}
class="group flex min-w-0 items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-3 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
class="home-sidebar-link group flex min-w-0 items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-2.5 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
>
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<span class="home-sidebar-link__icon">
<i class={`fas ${link.icon} text-[11px]`}></i>
</span>
<span class="min-w-0 truncate font-medium">{link.text}</span>
<span class="min-w-0 flex-1 truncate font-medium">{link.text}</span>
<i class="fas fa-arrow-right text-[10px] text-[var(--text-tertiary)] transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-[var(--primary)]"></i>
</a>
))}
</div>
<div class="space-y-1 border-t border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] pt-4">
<h4 class="text-sm font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinksMore}</h4>
</div>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
{secondaryQuickLinks.map(link => (
<a
href={link.href}
class="home-sidebar-mini-link group flex min-w-0 items-center gap-2.5 rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 px-3 py-2 text-xs text-[var(--text-secondary)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
>
<span class="home-sidebar-mini-link__icon">
<i class={`fas ${link.icon} text-[10px]`}></i>
</span>
<span class="min-w-0 flex-1 truncate font-medium">{link.text}</span>
</a>
))}
</div>
@@ -609,7 +679,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<SharePanel
shareTitle={siteSettings.siteTitle}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
summary={homeShareSummary}
canonicalUrl={siteBaseUrl}
badge={homeShareCopy.badge}
title={homeShareCopy.title}
@@ -618,22 +688,26 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
variant="compact"
/>
<section class="terminal-panel space-y-4">
<section class="terminal-panel home-sidebar-card space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<span class="terminal-kicker w-fit">
<i class="fas fa-fire"></i>
traffic
</span>
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.popularTitle}</h3>
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.popularDesc}</p>
</div>
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
</div>
<div class="home-popular-sortbar">
<div class="home-popular-rangebar">
{popularRangeOptions.map((option) => (
<button
type="button"
data-home-popular-range={option.key}
class:list={[
'home-popular-sort',
'home-popular-range',
option.key === activeContentRange.key && 'is-active'
]}
>
@@ -644,7 +718,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
<div id="home-popular-list" class="space-y-3">
{popularRangeCards.map(({ rangeKey, item, post }) => (
{popularRangeCards.map(({ rangeKey, rank, item, post }) => (
<a
href={`/articles/${post.slug}`}
data-home-popular-card
@@ -655,16 +729,15 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
data-home-slug={post.slug}
data-home-popular-views={item.pageViews}
data-home-popular-completes={item.readCompletes}
data-home-popular-depth={Math.round(item.avgProgressPercent)}
class:list={[
'block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]',
'home-sidebar-popular block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]',
!initialPopularVisibleKeys.has(`${rangeKey}:${post.slug}`) && 'hidden'
]}
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
{item.pageViews}
<span class="home-sidebar-popular__rank">
<span data-home-popular-rank-label>{rank}</span>
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
@@ -676,7 +749,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</span>
</div>
<h4 class="mt-2 line-clamp-2 text-sm font-semibold leading-6 text-[var(--title-color)]">{post.title}</h4>
<div class="mt-2 flex flex-wrap gap-2 text-xs text-[var(--text-secondary)]">
<div class="home-sidebar-popular__metrics">
<span>{t('home.views')}: {item.pageViews}</span>
<span>{t('home.completes')}: {item.readCompletes}</span>
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
@@ -692,9 +765,13 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
</section>
<section class="terminal-panel space-y-4">
<section class="terminal-panel home-sidebar-card space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<span class="terminal-kicker w-fit">
<i class="fas fa-chart-simple"></i>
stats
</span>
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.statsTitle}</h3>
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.statsDesc}</p>
</div>
@@ -702,41 +779,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2">
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
<p id="home-reading-views-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
<p id="home-reading-completes-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
<p id="home-reading-progress-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
<p id="home-reading-duration-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="home-sidebar-meta-list">
{systemStats.slice(0, 4).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 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 class="home-sidebar-meta-item">
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</span>
<span class="text-sm font-semibold text-[var(--title-color)]">{item.value}</span>
</div>
))}
</div>
</section>
{sidebarFriendLinks.length > 0 && (
<section class="terminal-panel space-y-4">
<section class="terminal-panel home-sidebar-card space-y-4">
<div class="space-y-1">
<span class="terminal-kicker w-fit">
<i class="fas fa-link"></i>
network
</span>
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.friendsTitle}</h3>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.friendsDesc}</p>
</div>
@@ -747,7 +828,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
href={friend.url}
target="_blank"
rel="noopener noreferrer"
class="group flex items-start gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
class="home-sidebar-friend group flex items-start gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
>
{friend.avatar ? (
<img
@@ -790,26 +871,61 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<div id="about" class="px-4">
<CommandPrompt command={t('home.promptAbout')} />
<div class="ml-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.22fr)_minmax(18rem,0.78fr)]">
<section class="terminal-panel space-y-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker w-fit">
<i class="fas fa-user-astronaut"></i>
home / profile
</span>
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3>
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
<h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.about')}</h3>
<p class="mt-2 max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{siteSettings.ownerBio}</p>
</div>
</div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.techStack')}</h3>
<span class="terminal-stat-pill">
<i class="fas fa-code-branch text-[var(--primary)]"></i>
{t('common.postsCount', { count: allPosts.length })}
</span>
</div>
<div class="space-y-3">
<div class="flex items-center gap-3">
<span class="terminal-section-icon">
<i class="fas fa-layer-group"></i>
</span>
<div>
<h4 class="text-base font-semibold text-[var(--title-color)]">{t('home.techStack')}</h4>
<p class="text-sm text-[var(--text-secondary)]">{homeAboutSectionCopy.techDesc}</p>
</div>
</div>
<TechStackList items={techStack} />
</div>
</section>
<section class="terminal-panel space-y-4">
<div class="space-y-2">
<span class="terminal-kicker w-fit">
<i class="fas fa-chart-column"></i>
home / metrics
</span>
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3>
<StatsList stats={systemStats} />
<h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.systemStatus')}</h3>
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{homeAboutSectionCopy.metricsDesc}</p>
</div>
</div>
<StatsList stats={systemStats} />
</section>
</div>
</div>
</div>
<div class="my-8 border-t border-[var(--border-color)]"></div>
<div class="px-4 pb-2">
<div id="site-brief" class="px-4 pb-2">
<div class="ml-4">
<DiscoveryBrief
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}
@@ -874,7 +990,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
const popularList = document.getElementById('home-popular-list');
const popularCount = document.getElementById('home-popular-count');
const popularRangeButtons = Array.from(document.querySelectorAll('[data-home-popular-range]'));
const popularSortButtons = Array.from(document.querySelectorAll('[data-home-popular-sort]'));
const readingWindowPill = document.getElementById('home-stats-window-pill');
const readingViewsValue = document.getElementById('home-reading-views-value');
const readingCompletesValue = document.getElementById('home-reading-completes-value');
@@ -902,7 +1017,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
category: initialHomeState.category || '',
tag: initialHomeState.tag || '',
range: initialHomeState.range || '7d',
popularSort: 'views',
};
function getActiveRange() {
@@ -928,37 +1042,20 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
});
}
function syncPopularSortButtons() {
popularSortButtons.forEach((button) => {
button.classList.toggle(
'is-active',
(button.getAttribute('data-home-popular-sort') || 'views') === state.popularSort
);
});
}
function sortPopularCards(cards) {
const metricKey =
state.popularSort === 'completes'
? 'data-home-popular-completes'
: state.popularSort === 'depth'
? 'data-home-popular-depth'
: 'data-home-popular-views';
return [...cards].sort((left, right) => {
const leftValue = Number(left.getAttribute(metricKey) || '0');
const rightValue = Number(right.getAttribute(metricKey) || '0');
if (rightValue !== leftValue) {
return rightValue - leftValue;
}
const leftViews = Number(left.getAttribute('data-home-popular-views') || '0');
const rightViews = Number(right.getAttribute('data-home-popular-views') || '0');
if (rightViews !== leftViews) {
return rightViews - leftViews;
}
const leftCompletes = Number(left.getAttribute('data-home-popular-completes') || '0');
const rightCompletes = Number(right.getAttribute('data-home-popular-completes') || '0');
if (rightCompletes !== leftCompletes) {
return rightCompletes - leftCompletes;
}
return String(left.getAttribute('data-home-slug') || '').localeCompare(
String(right.getAttribute('data-home-slug') || '')
);
@@ -975,18 +1072,14 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
function syncPromptText(total) {
const tokens = getActiveTokens();
const activeRange = getActiveRange();
const discoverCommand = tokens.length
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
: t('home.promptDiscoverDefault');
const postsCommand = tokens.length
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) });
const popularCommand = t('home.promptPopularRange', { label: activeRange.label || state.range });
promptApi?.set?.('home-discover-prompt', discoverCommand, { typing: false });
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
promptApi?.set?.('home-popular-prompt', popularCommand, { typing: false });
}
function syncRangeMetrics(filteredPopularCount) {
@@ -1144,8 +1237,12 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
});
const sortedPopular = sortPopularCards(filteredPopular);
popularCards.forEach((card) => card.classList.add('hidden'));
sortedPopular.slice(0, popularPreviewLimit).forEach((card) => {
sortedPopular.slice(0, popularPreviewLimit).forEach((card, index) => {
card.classList.remove('hidden');
const rankLabel = card.querySelector('[data-home-popular-rank-label]');
if (rankLabel) {
rankLabel.textContent = String(index + 1);
}
popularList?.appendChild(card);
});
@@ -1172,7 +1269,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
syncActiveSummary();
syncPostTagSelection();
syncPopularRangeButtons();
syncPopularSortButtons();
syncRangeMetrics(filteredPopular.length);
syncPromptText(total);
@@ -1224,13 +1320,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
});
});
popularSortButtons.forEach((button) => {
button.addEventListener('click', () => {
state.popularSort = button.getAttribute('data-home-popular-sort') || 'views';
applyHomeFilters(false);
});
});
postsRoot?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
if (!target) return;

View File

@@ -9,7 +9,7 @@ import FilterPill from '../../components/ui/FilterPill.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
export const prerender = false;
@@ -286,7 +286,7 @@ const reviewFaqJsonLd = buildFaqJsonLd(reviewFaqs);
siteSettings={siteSettings}
canonical={hasActiveFilters ? '/reviews' : undefined}
noindex={hasActiveFilters}
jsonLd={[...reviewsJsonLd, reviewFaqJsonLd].filter(Boolean)}
jsonLd={compactJsonLd([...reviewsJsonLd, reviewFaqJsonLd])}
>
<PageViewTracker pageType="reviews" entityId="reviews-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

View File

@@ -20,9 +20,7 @@ const isEnglish = locale.startsWith('en');
let tags: Tag[] = [];
let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
let tagsFailed = false;
try {
const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
apiClient.getTags(),
apiClient.getPosts(),
@@ -32,7 +30,6 @@ try {
if (tagsResult.status === 'fulfilled') {
tags = tagsResult.value;
} else {
tagsFailed = true;
console.error('Failed to fetch tags:', tagsResult.reason);
}
@@ -45,9 +42,6 @@ try {
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch tag detail data:', error);
}
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
const tag =
@@ -59,8 +53,8 @@ const tag =
if (!tag) {
return new Response(null, {
status: tagsFailed ? 503 : 404,
headers: tagsFailed ? { 'Retry-After': '120' } : undefined,
status: tagsResult.status !== 'fulfilled' ? 503 : 404,
headers: tagsResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -433,7 +433,7 @@ function getServer() {
},
async () => {
const data = await requestBackend('POST', '/ai/reindex');
return createToolResult('AI index rebuilt', data);
return createToolResult('AI index rebuild job queued', data);
}
);

View File

@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, devices } from "@playwright/test";
const backendBaseUrl = "http://127.0.0.1:5150";
const frontendBaseUrl = "http://127.0.0.1:4321";
const isCi = Boolean(process.env.CI);
const webPushChannel =
process.env.PLAYWRIGHT_WEB_PUSH_CHANNEL ??
(process.platform === "win32" ? "msedge" : undefined);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
export default defineConfig({
testDir: "./tests",
testMatch: /web-push\.real\.spec\.ts/,
fullyParallel: false,
workers: 1,
timeout: 180_000,
expect: {
timeout: 20_000,
},
reporter: [["list"]],
use: {
...devices["Desktop Chrome"],
channel: webPushChannel,
baseURL: frontendBaseUrl,
headless: true,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: [
{
command: "cargo loco start --server-and-worker",
cwd: path.resolve(repoRoot, "backend"),
url: `${backendBaseUrl}/api/site_settings`,
reuseExistingServer: false,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
DATABASE_URL:
process.env.DATABASE_URL ??
"postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
REDIS_URL: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
TERMI_ADMIN_LOCAL_LOGIN_ENABLED:
process.env.TERMI_ADMIN_LOCAL_LOGIN_ENABLED ?? "true",
},
},
{
command: "pnpm dev --host 127.0.0.1 --port 4321",
cwd: path.resolve(repoRoot, "frontend"),
url: frontendBaseUrl,
reuseExistingServer: false,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PUBLIC_API_BASE_URL: `${backendBaseUrl}/api`,
INTERNAL_API_BASE_URL: `${backendBaseUrl}/api`,
PUBLIC_IMAGE_ALLOWED_HOSTS: "127.0.0.1,127.0.0.1:5150",
},
},
],
});

View File

@@ -0,0 +1,122 @@
import { chromium, expect, test } from "@playwright/test";
const BACKEND_BASE_URL = "http://127.0.0.1:5150";
const FRONTEND_BASE_URL = "http://127.0.0.1:4321";
type AdminSubscriptionRecord = {
id: number;
channel_type: string;
target: string;
};
type AdminSubscriptionListResponse = {
subscriptions: AdminSubscriptionRecord[];
};
declare global {
interface Window {
__termiPushMessages?: unknown[];
__termiSubscriptionPopupReady?: boolean;
}
}
test("浏览器订阅后可以收到测试推送", async ({ request }, testInfo) => {
const context = await chromium.launchPersistentContext(
testInfo.outputPath("web-push-user-data"),
{
headless: true,
viewport: { width: 1280, height: 800 },
},
);
try {
await context.grantPermissions(["notifications"], {
origin: FRONTEND_BASE_URL,
});
const page = context.pages()[0] ?? (await context.newPage());
await page.addInitScript(() => {
window.__termiPushMessages = [];
navigator.serviceWorker?.addEventListener("message", (event) => {
if (event.data?.type === "termi:web-push-received") {
window.__termiPushMessages?.push(event.data.payload ?? null);
}
});
});
await page.goto(`${FRONTEND_BASE_URL}/maintenance?returnTo=%2F`);
await page.getByLabel("访问口令").fill("termi");
await page.getByRole("button", { name: "进入站点" }).click();
await page.waitForURL(`${FRONTEND_BASE_URL}/`);
await page.waitForFunction(
() => window.__termiSubscriptionPopupReady === true,
);
await page.locator("[data-subscription-popup-open]").click();
const subscribeResponsePromise = page.waitForResponse(
(response) =>
response.url().includes("/api/subscriptions/combined") &&
response.request().method() === "POST",
);
await page.locator("[data-subscription-popup-submit]").click();
const subscribeResponse = await subscribeResponsePromise;
expect(subscribeResponse.ok()).toBeTruthy();
await expect(
page.locator('[data-subscription-popup-status][data-state="success"]'),
).toBeVisible();
const endpoint = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription?.endpoint ?? null;
});
expect(endpoint).toBeTruthy();
const loginResponse = await request.post(
`${BACKEND_BASE_URL}/api/admin/session/login`,
{
data: {
username: "admin",
password: "admin123",
},
},
);
expect(loginResponse.ok()).toBeTruthy();
const subscriptionsResponse = await request.get(
`${BACKEND_BASE_URL}/api/admin/subscriptions`,
);
expect(subscriptionsResponse.ok()).toBeTruthy();
const subscriptionsPayload =
(await subscriptionsResponse.json()) as AdminSubscriptionListResponse;
const subscription = subscriptionsPayload.subscriptions.find(
(item) => item.channel_type === "web_push" && item.target === endpoint,
);
expect(subscription).toBeTruthy();
const testResponse = await request.post(
`${BACKEND_BASE_URL}/api/admin/subscriptions/${subscription?.id}/test`,
);
expect(testResponse.ok()).toBeTruthy();
await page.waitForFunction(
() =>
Array.isArray(window.__termiPushMessages) &&
window.__termiPushMessages.length > 0,
undefined,
{ timeout: 45_000 },
);
const messages = await page.evaluate(() => window.__termiPushMessages ?? []);
expect(messages.length).toBeGreaterThan(0);
} finally {
await context.close();
}
});