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 name: ui-regression
on: 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: workflow_dispatch:
jobs: jobs:
@@ -35,6 +20,11 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 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 - name: Install frontend deps
working-directory: frontend working-directory: frontend
@@ -48,7 +38,15 @@ jobs:
working-directory: playwright-smoke working-directory: playwright-smoke
run: pnpm install --frozen-lockfile 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 - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: playwright-smoke working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
@@ -69,71 +67,22 @@ jobs:
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321 VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
run: pnpm build 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 - name: Run frontend UI regression suite
id: ui_frontend id: ui_frontend
working-directory: playwright-smoke working-directory: playwright-smoke
continue-on-error: true continue-on-error: true
env: env:
PLAYWRIGHT_USE_BUILT_APP: '1' PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:frontend 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 - name: Run admin UI regression suite
id: ui_admin id: ui_admin
working-directory: playwright-smoke working-directory: playwright-smoke
continue-on-error: true continue-on-error: true
env: env:
PLAYWRIGHT_USE_BUILT_APP: '1' PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:admin 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 - name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success' if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1 run: exit 1

2
.gitignore vendored
View File

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

View File

@@ -89,9 +89,11 @@ pnpm dev
```powershell ```powershell
cd backend cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" $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 镜像) ### Docker生产部署使用 Gitea Package 镜像)
补充部署分层与反代说明见: 补充部署分层与反代说明见:

View File

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

View File

@@ -545,11 +545,6 @@ export interface TaxonomyPayload {
seoDescription?: string | null seoDescription?: string | null
} }
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
}
export interface AdminAiProviderTestResponse { export interface AdminAiProviderTestResponse {
provider: string provider: string
endpoint: 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, Star,
Tags, Tags,
Workflow, Workflow,
} from 'lucide-react' } from "lucide-react";
import { startTransition, useCallback, useEffect, useState } from 'react' import { startTransition, useCallback, useEffect, useState } from "react";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
import { toast } from 'sonner' import { toast } from "sonner";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import {
import { Skeleton } from '@/components/ui/skeleton' Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/admin-format";
import { import {
Table, Table,
TableBody, TableBody,
@@ -26,9 +33,9 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from "@/lib/api";
import { buildFrontendUrl } from '@/lib/frontend-url' import { buildFrontendUrl } from "@/lib/frontend-url";
import { import {
formatCommentScope, formatCommentScope,
formatPostStatus, formatPostStatus,
@@ -37,8 +44,12 @@ import {
formatPostVisibility, formatPostVisibility,
formatReviewStatus, formatReviewStatus,
formatReviewType, formatReviewType,
} from '@/lib/admin-format' } from "@/lib/admin-format";
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types' import type {
AdminAnalyticsResponse,
AdminDashboardResponse,
WorkerOverview,
} from "@/lib/types";
function StatCard({ function StatCard({
label, label,
@@ -46,17 +57,21 @@ function StatCard({
note, note,
icon: Icon, icon: Icon,
}: { }: {
label: string label: string;
value: number value: number;
note: string note: string;
icon: typeof Rss icon: typeof Rss;
}) { }) {
return ( return (
<Card className="bg-gradient-to-br from-card via-card to-background/70"> <Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6"> <CardContent className="flex items-start justify-between pt-6">
<div> <div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p> <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div> {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> <p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div> </div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary"> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
function formatAiSourceLabel(value: string) { function formatAiSourceLabel(value: string) {
switch (value) { switch (value) {
case 'chatgpt-search': case "chatgpt-search":
return 'ChatGPT Search' return "ChatGPT Search";
case 'perplexity': case "perplexity":
return 'Perplexity' return "Perplexity";
case 'copilot-bing': case "copilot-bing":
return 'Copilot / Bing' return "Copilot / Bing";
case 'gemini': case "gemini":
return 'Gemini' return "Gemini";
case 'claude': case "claude":
return 'Claude' return "Claude";
case 'google': case "google":
return 'Google' return "Google";
case 'duckduckgo': case "duckduckgo":
return 'DuckDuckGo' return "DuckDuckGo";
case 'kagi': case "kagi":
return 'Kagi' return "Kagi";
case 'direct': case "direct":
return 'Direct' return "Direct";
default: default:
return value return value;
} }
} }
export function DashboardPage() { export function DashboardPage() {
const [data, setData] = useState<AdminDashboardResponse | null>(null) const [data, setData] = useState<AdminDashboardResponse | null>(null);
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null) const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null) null,
const [loading, setLoading] = useState(true) );
const [refreshing, setRefreshing] = useState(false) const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadDashboard = useCallback(async (showToast = false) => { const loadDashboard = useCallback(async (showToast = false) => {
try { try {
if (showToast) { if (showToast) {
setRefreshing(true) setRefreshing(true);
} }
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([ const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(), adminApi.dashboard(),
adminApi.getWorkersOverview(), adminApi.getWorkersOverview(),
adminApi.analytics(), adminApi.analytics(),
]) ]);
startTransition(() => { startTransition(() => {
setData(next) setData(next);
setWorkerOverview(nextWorkerOverview) setWorkerOverview(nextWorkerOverview);
setAnalytics(nextAnalytics) setAnalytics(nextAnalytics);
}) });
if (showToast) { if (showToast) {
toast.success('仪表盘已刷新。') toast.success("仪表盘已刷新。");
} }
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
return return;
} }
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。') toast.error(
error instanceof ApiError ? error.message : "无法加载仪表盘。",
);
} finally { } finally {
setLoading(false) setLoading(false);
setRefreshing(false) setRefreshing(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
void loadDashboard(false) void loadDashboard(false);
}, [loadDashboard]) }, [loadDashboard]);
if (loading || !data || !workerOverview || !analytics) { if (loading || !data || !workerOverview || !analytics) {
return ( return (
@@ -147,24 +168,24 @@ export function DashboardPage() {
<Skeleton className="h-[420px] rounded-3xl" /> <Skeleton className="h-[420px] rounded-3xl" />
</div> </div>
</div> </div>
) );
} }
const statCards = [ const statCards = [
{ {
label: '文章总数', label: "文章总数",
value: data.stats.total_posts, value: data.stats.total_posts,
note: `内容库中共有 ${data.stats.total_comments} 条评论`, note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss, icon: Rss,
}, },
{ {
label: '待审核评论', label: "待审核评论",
value: data.stats.pending_comments, value: data.stats.pending_comments,
note: '等待审核处理', note: "等待审核处理",
icon: MessageSquareWarning, icon: MessageSquareWarning,
}, },
{ {
label: '发布待办', label: "发布待办",
value: value:
data.stats.draft_posts + data.stats.draft_posts +
data.stats.scheduled_posts + data.stats.scheduled_posts +
@@ -174,30 +195,32 @@ export function DashboardPage() {
icon: Clock3, icon: Clock3,
}, },
{ {
label: '分类数量', label: "分类数量",
value: data.stats.total_categories, value: data.stats.total_categories,
note: `当前共有 ${data.stats.total_tags} 个标签`, note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree, icon: FolderTree,
}, },
{ {
label: 'AI 分块', label: "AI 分块",
value: data.stats.ai_chunks, value: data.stats.ai_chunks,
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭', note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
icon: BrainCircuit, icon: BrainCircuit,
}, },
{ {
label: 'Worker 活动', label: "Worker 活动",
value: workerOverview.active_jobs, value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`, note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
icon: Workflow, icon: Workflow,
}, },
] ];
const aiTrafficShare = const aiTrafficShare =
analytics.content_overview.page_views_last_7d > 0 analytics.content_overview.page_views_last_7d > 0
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100 ? (analytics.ai_discovery_page_views_last_7d /
: 0 analytics.content_overview.page_views_last_7d) *
const topAiSource = analytics.ai_referrers_last_7d[0] 100
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length : 0;
const topAiSource = analytics.ai_referrers_last_7d[0];
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -207,14 +230,15 @@ export function DashboardPage() {
<div> <div>
<h2 className="text-3xl font-semibold tracking-tight"></h2> <h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground"> <p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI AI
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild> <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" /> <ArrowUpRight className="h-4 w-4" />
AI AI
</a> </a>
@@ -225,7 +249,7 @@ export function DashboardPage() {
disabled={refreshing} disabled={refreshing}
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'} {refreshing ? "刷新中..." : "刷新"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -241,9 +265,7 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="outline">{data.recent_posts.length} </Badge> <Badge variant="outline">{data.recent_posts.length} </Badge>
</CardHeader> </CardHeader>
@@ -265,9 +287,13 @@ export function DashboardPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title}</span> <span className="font-medium">{post.title}</span>
{post.pinned ? <Badge variant="success"></Badge> : null} {post.pinned ? (
<Badge variant="success"></Badge>
) : null}
</div> </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> </div>
</TableCell> </TableCell>
<TableCell className="uppercase text-muted-foreground"> <TableCell className="uppercase text-muted-foreground">
@@ -275,12 +301,18 @@ export function DashboardPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge> <Badge variant="outline">
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge> {formatPostStatus(post.status)}
</Badge>
<Badge variant="secondary">
{formatPostVisibility(post.visibility)}
</Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell>{post.category}</TableCell> <TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell> <TableCell className="text-muted-foreground">
{post.created_at}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -291,19 +323,19 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription> AI </CardDescription>
AI
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4"> <div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium">{data.site.site_name}</p> <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> </div>
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}> <Badge variant={data.site.ai_enabled ? "success" : "warning"}>
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'} {data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -314,7 +346,9 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 flex items-end gap-2"> <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" /> <Star className="mb-1 h-4 w-4 text-amber-500" />
</div> </div>
</div> </div>
@@ -323,7 +357,9 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 flex items-end gap-2"> <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" /> <Tags className="mb-1 h-4 w-4 text-primary" />
</div> </div>
</div> </div>
@@ -335,25 +371,35 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-3 sm:grid-cols-2">
<div> <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> <p className="text-xs text-muted-foreground">稿</p>
</div> </div>
<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> <p className="text-xs text-muted-foreground"></p>
</div> </div>
<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> <p className="text-xs text-muted-foreground">线</p>
</div> </div>
<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> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground"> <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.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge> <Badge variant="outline">
{data.stats.unlisted_posts}
</Badge>
</div> </div>
</div> </div>
@@ -362,7 +408,9 @@ export function DashboardPage() {
AI AI
</p> </p>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <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> </p>
</div> </div>
@@ -372,9 +420,13 @@ export function DashboardPage() {
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
GEO / AI GEO / AI
</p> </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"> <p className="mt-2 text-sm leading-6 text-muted-foreground">
7 ChatGPT SearchPerplexityCopilot/BingGeminiClaude 访 7 ChatGPT
SearchPerplexityCopilot/BingGeminiClaude
访
</p> </p>
</div> </div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary"> <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="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="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="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div> 访
<div className="mt-1 text-xs text-muted-foreground"> 7 page_view</div>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3"> <div className="mt-2 text-2xl font-semibold">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground"></div> {Math.round(aiTrafficShare)}%
<div className="mt-2 text-base font-semibold">
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'} 7 page_view
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-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="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
<div className="mt-1 text-xs text-muted-foreground"> AI </div> </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>
</div> </div>
@@ -408,15 +478,24 @@ export function DashboardPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => { {analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
const width = `${Math.max( 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, 8,
)}%` )}%`;
return ( return (
<div key={item.referrer} className="space-y-1.5"> <div key={item.referrer} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span> <span className="font-medium">
<span className="text-muted-foreground">{item.count}</span> {formatAiSourceLabel(item.referrer)}
</span>
<span className="text-muted-foreground">
{item.count}
</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-secondary"> <div className="h-2 overflow-hidden rounded-full bg-secondary">
<div <div
@@ -425,11 +504,13 @@ export function DashboardPage() {
/> />
</div> </div>
</div> </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> </div>
@@ -440,12 +521,17 @@ export function DashboardPage() {
Worker Worker
</p> </p>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-muted-foreground">
{workerOverview.queued} {workerOverview.running} {workerOverview.failed} {workerOverview.queued}{" "}
{workerOverview.running} {workerOverview.failed}
</p> </p>
</div> </div>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'} to={
workerOverview.failed > 0
? "/workers?status=failed"
: "/workers"
}
data-testid="dashboard-worker-open" data-testid="dashboard-worker-open"
> >
@@ -459,24 +545,36 @@ export function DashboardPage() {
data-testid="dashboard-worker-card-queued" 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" 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="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div> Queued
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.queued}
</div>
</Link> </Link>
<Link <Link
to="/workers?status=running" to="/workers?status=running"
data-testid="dashboard-worker-card-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" 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="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div> Running
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.running}
</div>
</Link> </Link>
<Link <Link
to="/workers?status=failed" to="/workers?status=failed"
data-testid="dashboard-worker-card-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" 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="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div> Failed
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.failed}
</div>
</Link> </Link>
</div> </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" 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>
<div className="font-medium text-foreground">{item.label}</div> <div className="font-medium text-foreground">
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div> {item.label}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.worker_name}
</div>
</div> </div>
<div className="text-right text-xs text-muted-foreground"> <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>ERR {item.failed}</div>
</div> </div>
</Link> </Link>
@@ -510,11 +614,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="warning">{data.pending_comments.length} </Badge> <Badge variant="warning">
{data.pending_comments.length}
</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
@@ -543,7 +647,9 @@ export function DashboardPage() {
<TableCell className="font-mono text-xs text-muted-foreground"> <TableCell className="font-mono text-xs text-muted-foreground">
{comment.post_slug} {comment.post_slug}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell> <TableCell className="text-muted-foreground">
{comment.created_at}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -556,11 +662,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="warning">{data.pending_friend_links.length} </Badge> <Badge variant="warning">
{data.pending_friend_links.length}
</Badge>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{data.pending_friend_links.map((link) => ( {data.pending_friend_links.map((link) => (
@@ -591,9 +697,7 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{data.recent_reviews.map((review) => ( {data.recent_reviews.map((review) => (
@@ -604,11 +708,14 @@ export function DashboardPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{review.title}</p> <p className="font-medium">{review.title}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)} {formatReviewType(review.review_type)} ·{" "}
{formatReviewStatus(review.status)}
</p> </p>
</div> </div>
<div className="text-right"> <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"> <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{review.review_date} {review.review_date}
</p> </p>
@@ -620,5 +727,5 @@ export function DashboardPage() {
</div> </div>
</div> </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 ```powershell
cargo loco start cargo loco start --server-and-worker
``` ```
默认本地监听: 默认本地监听:
- `http://localhost:5150` - `http://localhost:5150`
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
## 当前职责 ## 当前职责
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API - 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ use loco_rs::prelude::*;
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart}; use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel, ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
PaginatorTrait, QueryOrder, Set, Statement, PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -19,7 +19,7 @@ use uuid::Uuid;
use crate::{ use crate::{
controllers::site_settings as site_settings_controller, controllers::site_settings as site_settings_controller,
models::_entities::{ai_chunks, site_settings}, models::_entities::{ai_chunks, site_settings},
services::{content, storage}, services::{content, storage, worker_jobs},
}; };
const DEFAULT_AI_PROVIDER: &str = "openai"; const DEFAULT_AI_PROVIDER: &str = "openai";
@@ -36,6 +36,7 @@ const DEFAULT_TOP_K: usize = 4;
const DEFAULT_CHUNK_SIZE: usize = 1200; const DEFAULT_CHUNK_SIZE: usize = 1200;
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。"; const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
const EMBEDDING_BATCH_SIZE: usize = 32; const EMBEDDING_BATCH_SIZE: usize = 32;
pub(crate) const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
const EMBEDDING_DIMENSION: usize = 384; const EMBEDDING_DIMENSION: usize = 384;
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2"; 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"; 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>>, 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> { fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| { value.and_then(|item| {
let trimmed = item.trim().to_string(); 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>>> { 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 || { tokio::task::spawn_blocking(move || {
let model = local_embedding_engine()?; let model = local_embedding_engine()?;
let prepared = inputs let prepared = inputs
@@ -783,7 +804,7 @@ async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result
})?; })?;
let embeddings = guard let embeddings = guard
.embed(prepared, Some(EMBEDDING_BATCH_SIZE)) .embed(prepared, Some(batch_size.max(1)))
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?; .map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
Ok(embeddings 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( async fn load_runtime_settings(
ctx: &AppContext, ctx: &AppContext,
require_enabled: bool, require_enabled: bool,
@@ -2555,14 +2643,14 @@ async fn load_runtime_settings(
}) })
} }
async fn update_indexed_at( async fn update_indexed_at<C: ConnectionTrait>(
ctx: &AppContext, db: &C,
settings: &site_settings::Model, settings: &site_settings::Model,
) -> Result<DateTime<Utc>> { ) -> Result<DateTime<Utc>> {
let now = Utc::now(); let now = Utc::now();
let mut model = settings.clone().into_active_model(); let mut model = settings.clone().into_active_model();
model.ai_last_indexed_at = Set(Some(now.into())); model.ai_last_indexed_at = Set(Some(now.into()));
let _ = model.update(&ctx.db).await?; let _ = model.update(db).await?;
Ok(now) Ok(now)
} }
@@ -2571,14 +2659,8 @@ async fn retrieve_matches(
settings: &AiRuntimeSettings, settings: &AiRuntimeSettings,
question: &str, question: &str,
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> { ) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize; let 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); let 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;
}
if indexed_chunks == 0 { if indexed_chunks == 0 {
return Ok((Vec::new(), 0, last_indexed_at)); return Ok((Vec::new(), 0, last_indexed_at));
@@ -2640,32 +2722,49 @@ async fn retrieve_matches(
Ok((matches, indexed_chunks, last_indexed_at)) 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 settings = load_runtime_settings(ctx, false).await?;
let posts = content::load_markdown_posts_from_store(ctx).await?; let posts = content::load_markdown_posts_from_store(ctx).await?;
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size); let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size)); chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
let embeddings = if chunk_drafts.is_empty() { let total_chunks = chunk_drafts.len();
Vec::new() let batch_size = REINDEX_EMBEDDING_BATCH_SIZE.max(1);
let preparing_progress = build_reindex_progress(
"preparing",
if total_chunks == 0 {
"没有可写入的内容,正在清理旧索引。".to_string()
} else { } else {
embed_texts_locally( format!("已收集 {total_chunks} 个分块,准备重建向量索引。")
chunk_drafts },
.iter() total_chunks,
.map(|chunk| chunk.content.clone()) 0,
.collect::<Vec<_>>(), batch_size,
EmbeddingKind::Passage, );
) update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
.await? stop_reindex_if_cancel_requested(ctx, job_id).await?;
}; let txn = ctx.db.begin().await?;
ctx.db txn.execute(Statement::from_string(
.execute(Statement::from_string(
DbBackend::Postgres, DbBackend::Postgres,
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(), "TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
)) ))
.await?; .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 embedding_literal = vector_literal(&embedding)?;
let statement = Statement::from_sql_and_values( let statement = Statement::from_sql_and_values(
DbBackend::Postgres, DbBackend::Postgres,
@@ -2696,10 +2795,27 @@ pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
draft.word_count.into(), 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 { Ok(AiIndexSummary {
indexed_chunks: chunk_drafts.len(), 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_EXHAUSTED: &str = "exhausted";
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped"; 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)] #[derive(Clone, Debug, Serialize)]
pub struct DigestDispatchSummary { pub struct DigestDispatchSummary {
@@ -259,6 +264,97 @@ fn merge_browser_push_metadata(
Value::Object(object) 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> { fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
value value
.and_then(Value::as_object) .and_then(Value::as_object)
@@ -321,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
values 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) { fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
match period.trim().to_ascii_lowercase().as_str() { match period.trim().to_ascii_lowercase().as_str() {
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY), "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.status = Set(STATUS_ACTIVE.to_string());
active.confirm_token = Set(None); active.confirm_token = Set(None);
active.verified_at = Set(Some(Utc::now().to_rfc3339())); 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( active.metadata = Set(Some(merge_browser_push_metadata(
existing.metadata.as_ref(), existing.metadata.as_ref(),
metadata, metadata,
@@ -1066,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
} }
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value { fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
let body = truncate_chars(&collapse_whitespace(&message.text), 220); let title = truncate_chars(
&collapse_whitespace(&message.subject),
serde_json::json!({ WEB_PUSH_TITLE_MAX_CHARS,
"title": message.subject, );
"body": body, let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"), let url = web_push_target_url(message);
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"), let event_type = message
"url": web_push_target_url(message),
"tag": message
.payload .payload
.get("event_type") .get("event_type")
.and_then(Value::as_str) .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": { "data": {
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null), "url": url,
"payload": message.payload, "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( async fn deliver_via_channel(
ctx: &AppContext, ctx: &AppContext,
channel_type: &str, channel_type: &str,
@@ -1126,7 +1239,7 @@ async fn deliver_via_channel(
CHANNEL_WEB_PUSH => { CHANNEL_WEB_PUSH => {
let settings = crate::controllers::site_settings::load_current(ctx).await?; let settings = crate::controllers::site_settings::load_current(ctx).await?;
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?; 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( web_push_service::send_payload(
&settings, &settings,
&subscription_info, &subscription_info,
@@ -1275,24 +1388,25 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.await?; .await?;
} }
Err(error) => { Err(error) => {
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS) let error_text = error.to_string();
.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 mut active = delivery.into_active_model(); 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.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.attempts_count = Set(attempts);
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339())); 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())); active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?; 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)?; Err(error)?;
} }
} }

View File

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

View File

@@ -11,6 +11,7 @@ use crate::{
models::_entities::{notification_deliveries, worker_jobs}, models::_entities::{notification_deliveries, worker_jobs},
services::subscriptions, services::subscriptions,
workers::{ workers::{
ai_reindex::{AiReindexWorker, AiReindexWorkerArgs},
downloader::{DownloadWorker, DownloadWorkerArgs}, downloader::{DownloadWorker, DownloadWorkerArgs},
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs}, 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_DOWNLOAD_MEDIA: &str = "worker.download_media";
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery"; 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_RETRY_DELIVERIES: &str = "task.retry_deliveries";
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest"; pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_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> { fn queue_name_for(worker_name: &str) -> Option<String> {
match worker_name { match worker_name {
WORKER_AI_REINDEX => Some("ai".to_string()),
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()), WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()), WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
TASK_RETRY_DELIVERIES => Some("maintenance".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 { fn label_for(worker_name: &str) -> String {
match worker_name { match worker_name {
WORKER_AI_REINDEX => "AI 索引重建".to_string(),
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(), WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(), WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
TASK_RETRY_DELIVERIES => "重试待投递通知".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 { fn description_for(worker_name: &str) -> String {
match worker_name { match worker_name {
WORKER_AI_REINDEX => "按当前站点内容重新生成 AI 检索索引,并分批写入向量数据。".to_string(),
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(), WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(), WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".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 { fn tags_for(worker_name: &str) -> Value {
match worker_name { match worker_name {
WORKER_AI_REINDEX => json!(["ai", "reindex"]),
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]), WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]), WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]), TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
@@ -249,6 +255,7 @@ fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
fn catalog_entries() -> Vec<WorkerCatalogEntry> { fn catalog_entries() -> Vec<WorkerCatalogEntry> {
[ [
(WORKER_AI_REINDEX, JOB_KIND_WORKER, true, true),
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true), (WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true), (WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, 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( async fn dispatch_notification_delivery(
args_ctx: AppContext, args_ctx: AppContext,
args: NotificationDeliveryWorkerArgs, 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( async fn enqueue_notification_worker(
ctx: &AppContext, ctx: &AppContext,
args: NotificationDeliveryWorkerArgs, args: NotificationDeliveryWorkerArgs,
@@ -587,6 +606,28 @@ pub async fn mark_job_succeeded(ctx: &AppContext, id: i32, result: Option<Value>
Ok(()) 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<()> { pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
let item = find_job(ctx, id).await?; let item = find_job(ctx, id).await?;
let mut active = item.into_active_model(); let mut active = item.into_active_model();
@@ -681,6 +722,13 @@ pub async fn queue_notification_delivery_job(
.one(&ctx.db) .one(&ctx.db)
.await? .await?
.ok_or(Error::NotFound)?; .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 { let base_args = NotificationDeliveryWorkerArgs {
delivery_id, delivery_id,
@@ -717,6 +765,46 @@ pub async fn queue_notification_delivery_job(
get_job_record(ctx, job.id).await 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( pub async fn spawn_retry_deliveries_task(
ctx: &AppContext, ctx: &AppContext,
limit: Option<u64>, limit: Option<u64>,
@@ -810,6 +898,17 @@ pub async fn retry_job(
let payload = item.payload.clone().unwrap_or(Value::Null); let payload = item.payload.clone().unwrap_or(Value::Null);
match item.worker_name.as_str() { 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 => { WORKER_DOWNLOAD_MEDIA => {
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?; let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
queue_download_job( 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 downloader;
pub mod notification_delivery; pub mod notification_delivery;

View File

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

View File

@@ -3,6 +3,17 @@ BACKEND_PORT=5150
FRONTEND_PORT=4321 FRONTEND_PORT=4321
ADMIN_PORT=4322 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 默认可直接使用) # frontend SSR 服务端访问 backend 用这个内部地址compose 默认可直接使用)
INTERNAL_API_BASE_URL=http://backend:5150/api INTERNAL_API_BASE_URL=http://backend:5150/api

View File

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

View File

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

View File

@@ -3,6 +3,10 @@ services:
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest} image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
# 对 tohka 这类小内存主机,建议给服务设置明确上限,
# 避免 AI 重建索引时把整机拖进 swap 抖动 / OOM。
mem_limit: ${BACKEND_MEMORY_LIMIT:-768m}
memswap_limit: ${BACKEND_MEMORY_SWAP_LIMIT:-768m}
environment: environment:
PORT: 5150 PORT: 5150
APP_BASE_URL: ${APP_BASE_URL:-http://localhost: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} image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
mem_limit: ${BACKEND_WORKER_MEMORY_LIMIT:-1g}
memswap_limit: ${BACKEND_WORKER_MEMORY_SWAP_LIMIT:-1g}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -48,11 +54,22 @@ services:
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-} TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
RUST_LOG: ${RUST_LOG:-info} RUST_LOG: ${RUST_LOG:-info}
TERMI_SKIP_MIGRATIONS: 'true' 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: frontend:
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest} image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
mem_limit: ${FRONTEND_MEMORY_LIMIT:-256m}
memswap_limit: ${FRONTEND_MEMORY_SWAP_LIMIT:-256m}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -78,6 +95,8 @@ services:
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest} image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
mem_limit: ${ADMIN_MEMORY_LIMIT:-128m}
memswap_limit: ${ADMIN_MEMORY_SWAP_LIMIT:-128m}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View File

@@ -25,6 +25,14 @@ compose_env:
BACKEND_PORT: 5150 BACKEND_PORT: 5150
FRONTEND_PORT: 4321 FRONTEND_PORT: 4321
ADMIN_PORT: 4322 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 APP_BASE_URL: https://admin.blog.init.cool
INTERNAL_API_BASE_URL: http://backend:5150/api INTERNAL_API_BASE_URL: http://backend:5150/api

View File

@@ -105,8 +105,8 @@ function Start-Backend {
-Run { -Run {
$env:DATABASE_URL = $DatabaseUrl $env:DATABASE_URL = $DatabaseUrl
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
cargo loco start 2>&1 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<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" /> <defs>
<style> <linearGradient id="bg" x1="18" y1="10" x2="110" y2="118" gradientUnits="userSpaceOnUse">
path { fill: #000; } <stop stop-color="#132334" />
@media (prefers-color-scheme: dark) { <stop offset="1" stop-color="#081019" />
path { fill: #FFF; } </linearGradient>
} <linearGradient id="toolbar" x1="20" y1="18" x2="108" y2="40" gradientUnits="userSpaceOnUse">
</style> <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> </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 = (() => { const payload = (() => {
if (!event.data) { if (!event.data) {
return {}; return {};
@@ -13,30 +13,50 @@ self.addEventListener('push', (event) => {
} }
})(); })();
const title = payload.title || '订阅更新'; const notifyClients = self.clients
const url = payload.url || '/'; .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 = { const options = {
body: payload.body || '', body: payload.body || "",
icon: payload.icon || '/favicon.svg', icon: payload.icon || "/favicon.svg",
badge: payload.badge || '/favicon.ico', badge: payload.badge || "/favicon.ico",
tag: payload.tag || 'termi-subscription', tag: payload.tag || "termi-subscription",
data: { data: {
url, url,
...(payload.data || {}), ...(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(); event.notification.close();
const targetUrl = event.notification?.data?.url || '/'; const targetUrl = event.notification?.data?.url || "/";
event.waitUntil( 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) { for (const client of clients) {
if ('focus' in client && client.url === targetUrl) { if ("focus" in client && client.url === targetUrl) {
return client.focus(); return client.focus();
} }
} }

View File

@@ -17,7 +17,8 @@ const {
const { locale, t, buildLocaleUrl } = getI18n(Astro); const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled); const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true; 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() (item) => item?.title?.trim() && item?.url?.trim()
); );
const musicPlaylistPayload = JSON.stringify(musicPlaylist); const musicPlaylistPayload = JSON.stringify(musicPlaylist);
@@ -34,6 +35,14 @@ const navItems = [
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' }, { icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []), ...(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) => ({ const localeLinks = SUPPORTED_LOCALES.map((item) => ({
locale: item, locale: item,
href: buildLocaleUrl(item), href: buildLocaleUrl(item),
@@ -148,7 +157,7 @@ const currentNavLabel =
{aiEnabled && ( {aiEnabled && (
<a <a
href="/ask" 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> <i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span> <span class="hidden xl:inline">{t('nav.ask')}</span>
@@ -356,6 +365,40 @@ const currentNavLabel =
</div> </div>
</header> </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 }}> <script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
const t = window.__termiTranslate; const t = window.__termiTranslate;
@@ -364,12 +407,16 @@ const currentNavLabel =
const mobileMenu = document.getElementById('mobile-menu'); const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input'); const mobileSearchInput = document.getElementById('mobile-search-input');
const mobileSearchBtn = document.getElementById('mobile-search-btn'); 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'); const nextExpanded = mobileMenu?.classList.contains('hidden');
mobileMenu?.classList.toggle('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) => { document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
link.addEventListener('click', () => { link.addEventListener('click', () => {

View File

@@ -10,10 +10,10 @@ const { stats } = Astro.props;
<ul class="grid gap-3"> <ul class="grid gap-3">
{stats.map((stat, index) => ( {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 items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3"> <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 class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
</span> </span>
<div class="min-w-0"> <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 class="mt-1 text-lg font-semibold text-[var(--title-color)]">{stat.value}</div>
</div> </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> </div>
</li> </li>
))} ))}

View File

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

View File

@@ -16,7 +16,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
<div <div
id="toc-panel" 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"> <div class="space-y-4">
<span class="terminal-kicker w-fit"> <span class="terminal-kicker w-fit">
@@ -130,8 +130,8 @@ const hasBeforeNav = Astro.slots.has('before-nav');
height: 0.52rem; height: 0.52rem;
margin-top: 0.42rem; margin-top: 0.42rem;
border-radius: 999px; border-radius: 999px;
background: color-mix(in oklab, var(--border-color) 82%, white 18%); background: color-mix(in oklab, var(--border-color) 82%, var(--terminal-bg));
box-shadow: 0 0 0 6px color-mix(in oklab, var(--card-bg, white) 92%, transparent); box-shadow: 0 0 0 6px color-mix(in oklab, var(--terminal-bg) 92%, transparent);
transition: transition:
background-color 160ms ease, background-color 160ms ease,
transform 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 { #toc-nav .toc-link.is-active .toc-link-dot {
background: var(--primary); background: var(--primary);
transform: scale(1.05); 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 { #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 tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container'); const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true'; const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
const header = document.querySelector('header');
if (!tocNav || headings.length === 0) { if (!tocNav || headings.length === 0) {
if (tocPanel) tocPanel.style.display = 'none'; if (tocPanel) tocPanel.style.display = 'none';
@@ -196,6 +197,47 @@ const hasBeforeNav = Astro.slots.has('before-nav');
return; 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 = ''; tocNav.innerHTML = '';
headings.forEach((heading, index) => { headings.forEach((heading, index) => {
if (!heading.id) { if (!heading.id) {
@@ -212,32 +254,103 @@ const hasBeforeNav = Astro.slots.has('before-nav');
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' }); setActiveLink(heading.id);
scrollToHeading(heading);
window.history.replaceState(null, '', `#${heading.id}`);
}); });
tocNav.appendChild(link); tocNav.appendChild(link);
}); });
const observer = new IntersectionObserver( updateHeadingOffset();
(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' }
);
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') { 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"> <ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => ( {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"> <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> <i class="fas fa-code text-xs"></i>
</span> </span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">

View File

@@ -31,7 +31,7 @@ const {
title = isEnglish ? 'Quick share' : '一键分享', title = isEnglish ? 'Quick share' : '一键分享',
description = isEnglish description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.' ? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。', : '复制链接、带走二维码,轻轻一发,不剧透太多。',
stats = [], stats = [],
wechatShareQrEnabled = false, wechatShareQrEnabled = false,
variant = 'default', variant = 'default',
@@ -70,30 +70,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready', toastInfoTitle: 'Share ready',
} }
: { : {
summaryTitle: '页面简介', summaryTitle: '小纸条',
canonical: '固定链接', canonical: '传送门',
copySummary: '复制简介', copySummary: '复制小纸条',
copySummarySuccess: '页面简介已复制', copySummarySuccess: '小纸条已复制',
copySummaryFailed: '复制失败', copySummaryFailed: '复制失败',
copyLink: '复制固定链接', copyLink: '复制传送门',
copyLinkSuccess: '固定链接已复制', copyLinkSuccess: '传送门已复制',
copyLinkFailed: '固定链接复制失败', copyLinkFailed: '传送门复制失败',
shareSummary: '直接分享', shareSummary: '一键甩出',
shareSuccess: '已打开系统分享', shareSuccess: '系统分享已就位',
shareFallback: '分享内容已复制', shareFallback: '分享话术已复制',
shareFailed: '分享失败', shareFailed: '分享失败',
shareToX: '分享到 X', shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram', shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫一扫', shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫', qrModalTitle: '微信扫一扫',
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。', qrModalDescription: '扫一下,手机上继续逛。',
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。', qrModalHint: '真要转发,丢链接通常更省事。',
downloadQr: '下载二维码', downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载', downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开', qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成', toastSuccessTitle: '搞定',
toastErrorTitle: '操作失败', toastErrorTitle: '这次没接住',
toastInfoTitle: '已准备好', toastInfoTitle: '可以发了',
}; };
const safeSummary = summary.trim() || shareTitle; const safeSummary = summary.trim() || shareTitle;
@@ -140,7 +140,9 @@ if (wechatShareQrEnabled) {
<section <section
class:list={[ 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', isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6',
]} ]}
data-share-panel-id={panelId} 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:list={['flex flex-col gap-4', !isCompact && 'lg:flex-row lg:items-start lg:justify-between']}>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap items-center gap-2"> <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> <i class="fas fa-satellite-dish text-[10px]"></i>
{visibleBadge} {visibleBadge}
</span> </span>
@@ -167,7 +169,7 @@ if (wechatShareQrEnabled) {
{stats.length > 0 ? ( {stats.length > 0 ? (
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}> <div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}>
{stats.slice(0, 4).map((item) => ( {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="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="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
</div> </div>
@@ -177,7 +179,7 @@ if (wechatShareQrEnabled) {
</div> </div>
<div class:list={[ <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', isCompact ? 'mt-4 p-4' : 'mt-5 p-5',
]}> ]}>
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div> <div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
@@ -186,6 +188,91 @@ if (wechatShareQrEnabled) {
</p> </p>
</div> </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="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <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> <p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div> </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="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'} {isEnglish ? 'Share channels' : '分享渠道'}
</p> </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>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a <a
@@ -268,20 +355,24 @@ if (wechatShareQrEnabled) {
</div> </div>
</div> </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> <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> <p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </div>
{wechatShareQrEnabled && wechatShareQrSvg ? ( {wechatShareQrEnabled && wechatShareQrSvg ? (
<div <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 data-share-wechat-modal
aria-hidden="true" aria-hidden="true"
> >
<div class="flex min-h-screen items-center justify-center p-4"> <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="flex items-start justify-between gap-4">
<div class="space-y-2"> <div class="space-y-2">
<span class="terminal-kicker"> <span class="terminal-kicker">
@@ -298,7 +389,7 @@ if (wechatShareQrEnabled) {
<button <button
type="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 data-share-wechat-close
aria-label={t('common.close')} aria-label={t('common.close')}
> >
@@ -307,19 +398,19 @@ if (wechatShareQrEnabled) {
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]"> <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="share-wechat-modal-surface mx-auto w-full max-w-[260px] rounded-[28px] p-5">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div> <div class="overflow-hidden rounded-2xl bg-white" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <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)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical} {copy.canonical}
</div> </div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p> <p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </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)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle} {copy.summaryTitle}
</div> </div>
@@ -363,6 +454,7 @@ if (wechatShareQrEnabled) {
</div> </div>
</div> </div>
</div> </div>
</div>
) : null} ) : null}
<div <div
@@ -393,6 +485,118 @@ if (wechatShareQrEnabled) {
</div> </div>
</section> </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 <script
is:inline is:inline
define:vars={{ define:vars={{
@@ -424,6 +628,10 @@ if (wechatShareQrEnabled) {
const toastMessage = root.querySelector('[data-share-toast-message]'); const toastMessage = root.querySelector('[data-share-toast-message]');
let toastTimer = 0; let toastTimer = 0;
if (wechatModal instanceof HTMLElement && wechatModal.parentElement !== document.body) {
document.body.appendChild(wechatModal);
}
function setStatus(message) { function setStatus(message) {
if (!status) return; if (!status) return;
status.textContent = message || ''; status.textContent = message || '';

View File

@@ -150,7 +150,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
/> />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<title>{title}</title> <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" /> <slot name="head" />
<style is:inline> <style is:inline>
@@ -467,8 +467,6 @@ const i18nPayload = JSON.stringify({ locale, messages });
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
media="print"
onload="this.media='all'"
/> />
<noscript> <noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" /> <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 <link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" 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" rel="stylesheet"
media="print"
onload="this.media='all'"
> >
<noscript> <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"> <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} /> <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 /> <slot />
</main> </main>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,8 @@ export interface DiscoveryFaqOptions {
signals?: string[]; signals?: string[];
} }
export type JsonLdObject = Record<string, unknown>;
function normalizeWhitespace(value: string): string { function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim(); 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) { export function buildPostItemList(posts: Post[], siteUrl: string) {
return posts.map((post, index) => ({ return posts.map((post, index) => ({
'@type': 'ListItem', '@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 = { export type BrowserPushSubscriptionPayload = {
endpoint: string; endpoint: string;
@@ -9,21 +9,26 @@ export type BrowserPushSubscriptionPayload = {
}; };
}; };
export type BrowserPushSubscriptionState = {
subscription: PushSubscription | null;
stale: boolean;
};
function ensureBrowserSupport() { function ensureBrowserSupport() {
if ( if (
typeof window === 'undefined' || typeof window === "undefined" ||
!('Notification' in window) || !("Notification" in window) ||
!('serviceWorker' in navigator) || !("serviceWorker" in navigator) ||
!('PushManager' in window) !("PushManager" in window)
) { ) {
throw new Error('当前浏览器不支持 Web Push。'); throw new Error("当前浏览器不支持 Web Push。");
} }
} }
function urlBase64ToUint8Array(base64String: string) { function urlBase64ToUint8Array(base64String: string) {
const normalized = base64String.trim(); const normalized = base64String.trim();
const padding = '='.repeat((4 - (normalized.length % 4)) % 4); const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/'); const base64 = (normalized + padding).replace(/-/g, "+").replace(/_/g, "/");
const binary = window.atob(base64); const binary = window.atob(base64);
const output = new Uint8Array(binary.length); const output = new Uint8Array(binary.length);
@@ -34,22 +39,66 @@ function urlBase64ToUint8Array(base64String: string) {
return output; 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() { async function getRegistration() {
ensureBrowserSupport(); ensureBrowserSupport();
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' }); await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" });
return navigator.serviceWorker.ready; return navigator.serviceWorker.ready;
} }
function normalizeSubscription( function normalizeSubscription(
subscription: PushSubscription | PushSubscriptionJSON, subscription: PushSubscription | PushSubscriptionJSON,
): BrowserPushSubscriptionPayload { ): BrowserPushSubscriptionPayload {
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription; const json = "toJSON" in subscription ? subscription.toJSON() : subscription;
const endpoint = json.endpoint?.trim() || ''; const endpoint = json.endpoint?.trim() || "";
const auth = json.keys?.auth?.trim() || ''; const auth = json.keys?.auth?.trim() || "";
const p256dh = json.keys?.p256dh?.trim() || ''; const p256dh = json.keys?.p256dh?.trim() || "";
if (!endpoint || !auth || !p256dh) { if (!endpoint || !auth || !p256dh) {
throw new Error('浏览器返回的 PushSubscription 不完整。'); throw new Error("浏览器返回的 PushSubscription 不完整。");
} }
return { return {
@@ -64,20 +113,53 @@ function normalizeSubscription(
export function supportsBrowserPush() { export function supportsBrowserPush() {
return ( return (
typeof window !== 'undefined' && typeof window !== "undefined" &&
'Notification' in window && "Notification" in window &&
'serviceWorker' in navigator && "serviceWorker" in navigator &&
'PushManager' in window "PushManager" in window
); );
} }
export async function getBrowserPushSubscription() { export async function getBrowserPushSubscriptionState(
publicKey?: string,
): Promise<BrowserPushSubscriptionState> {
if (!supportsBrowserPush()) { if (!supportsBrowserPush()) {
return null; return {
subscription: null,
stale: false,
};
} }
const registration = await getRegistration(); 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( export async function ensureBrowserPushSubscription(
@@ -86,25 +168,34 @@ export async function ensureBrowserPushSubscription(
ensureBrowserSupport(); ensureBrowserSupport();
if (!publicKey.trim()) { if (!publicKey.trim()) {
throw new Error('Web Push 公钥未配置。'); throw new Error("Web Push 公钥未配置。");
} }
const permission = const permission =
Notification.permission === 'granted' Notification.permission === "granted"
? 'granted' ? "granted"
: await Notification.requestPermission(); : await Notification.requestPermission();
if (permission !== 'granted') { if (permission !== "granted") {
throw new Error('浏览器通知权限未开启。'); throw new Error("浏览器通知权限未开启。");
} }
const registration = await getRegistration(); const registration = await getRegistration();
const publicKeyBytes = urlBase64ToUint8Array(publicKey);
let subscription = await registration.pushManager.getSubscription(); let subscription = await registration.pushManager.getSubscription();
if (
subscription &&
!subscriptionMatchesPublicKey(subscription, publicKeyBytes)
) {
await subscription.unsubscribe().catch(() => undefined);
subscription = null;
}
if (!subscription) { if (!subscription) {
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, 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 TechStackList from '../../components/TechStackList.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'; import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo'; import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
export const prerender = false; export const prerender = false;
@@ -130,7 +130,7 @@ const aboutJsonLd = [
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`} title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
description={siteSettings.siteDescription} description={siteSettings.siteDescription}
siteSettings={siteSettings} siteSettings={siteSettings}
jsonLd={aboutJsonLd.filter(Boolean)} jsonLd={compactJsonLd(aboutJsonLd)}
> >
<PageViewTracker pageType="about" entityId="about" /> <PageViewTracker pageType="about" entityId="about" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

View File

@@ -23,6 +23,7 @@ import {
buildArticleHighlights, buildArticleHighlights,
buildArticlePreviewParagraphs, buildArticlePreviewParagraphs,
buildArticleSynopsis, buildArticleSynopsis,
compactJsonLd,
resolvePostUpdatedAt, resolvePostUpdatedAt,
} from '../../lib/seo'; } from '../../lib/seo';
import type { PopularPostHighlight } from '../../lib/types'; import type { PopularPostHighlight } from '../../lib/types';
@@ -45,7 +46,6 @@ let post = null;
let siteSettings = DEFAULT_SITE_SETTINGS; let siteSettings = DEFAULT_SITE_SETTINGS;
const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`; const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`;
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null; let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
let postLookupFailed = false;
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([ const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
apiClient.getPostBySlug(slug ?? ''), apiClient.getPostBySlug(slug ?? ''),
@@ -56,7 +56,6 @@ const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettle
if (postResult.status === 'fulfilled') { if (postResult.status === 'fulfilled') {
post = postResult.value; post = postResult.value;
} else { } else {
postLookupFailed = true;
console.error('API Error:', postResult.reason); console.error('API Error:', postResult.reason);
} }
@@ -77,8 +76,8 @@ if (homeDataResult.status === 'fulfilled') {
if (!post) { if (!post) {
return new Response(null, { return new Response(null, {
status: postLookupFailed ? 503 : 404, status: postResult.status !== 'fulfilled' ? 503 : 404,
headers: postLookupFailed ? { 'Retry-After': '120' } : undefined, headers: postResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
}); });
} }
@@ -371,7 +370,7 @@ const breadcrumbJsonLd = {
ogImage={ogImage} ogImage={ogImage}
ogType="article" ogType="article"
twitterCard="summary_large_image" twitterCard="summary_large_image"
jsonLd={[articleJsonLd, breadcrumbJsonLd, faqJsonLd].filter(Boolean)} jsonLd={compactJsonLd([articleJsonLd, breadcrumbJsonLd, faqJsonLd])}
> >
<Fragment slot="head"> <Fragment slot="head">
<meta property="article:published_time" content={publishedAt} /> <meta property="article:published_time" content={publishedAt} />
@@ -438,7 +437,7 @@ const breadcrumbJsonLd = {
</div> </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)]"> <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 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 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> <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> <p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
</div> </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"> <div class="space-y-3">
{articlePreviewParagraphs.map((paragraph) => ( {articlePreviewParagraphs.map((paragraph) => (
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p> <p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
@@ -501,7 +500,7 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]"> <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="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <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"> <div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
{digestStats.map((item) => ( {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="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 class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
</div> </div>
@@ -559,7 +558,7 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="space-y-4"> <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)]"> <h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.sourceTitle} {articleCopy.sourceTitle}
</h3> </h3>
@@ -602,13 +601,13 @@ const breadcrumbJsonLd = {
</div> </div>
{articleHighlights.length > 0 && ( {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)]"> <h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle} {articleCopy.highlightsTitle}
</h3> </h3>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
{articleHighlights.map((item, index) => ( {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)]"> <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} {index + 1}
</span> </span>
@@ -829,7 +828,7 @@ const breadcrumbJsonLd = {
> >
<button <button
type="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" data-article-floating-action="digest-copy"
title={articleCopy.copySummary} title={articleCopy.copySummary}
aria-label={articleCopy.copySummary} aria-label={articleCopy.copySummary}
@@ -838,7 +837,7 @@ const breadcrumbJsonLd = {
</button> </button>
<button <button
type="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" data-article-floating-action="digest-share"
title={articleCopy.shareSummary} title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary} aria-label={articleCopy.shareSummary}
@@ -848,7 +847,7 @@ const breadcrumbJsonLd = {
{wechatShareQrEnabled && wechatShareQrSvg && ( {wechatShareQrEnabled && wechatShareQrSvg && (
<button <button
type="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" data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat} title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat} aria-label={articleCopy.shareToWeChat}
@@ -858,7 +857,7 @@ const breadcrumbJsonLd = {
)} )}
<button <button
type="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" data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')} title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')} aria-label={t('common.copyPermalink')}
@@ -904,19 +903,19 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]"> <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 class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <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)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical} {articleCopy.canonical}
</div> </div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p> <p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </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)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle} {articleCopy.digestTitle}
</div> </div>

View File

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

View File

@@ -4,9 +4,10 @@ import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro'; import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro'; import SharePanel from '../../components/seo/SharePanel.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.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 { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo'; import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
export const prerender = false; export const prerender = false;
@@ -70,14 +71,16 @@ const sharePanelCopy = isEnglish
description: description:
'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.', 'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
examples: 'Prompts', examples: 'Prompts',
ai: 'AI', status: 'Status',
statusValue: aiEnabled ? 'Ready' : 'Idle',
} }
: { : {
badge: '问答入口', badge: '问答入口',
title: '分享问答页', title: '把问答入口甩出去',
description: '把这个问答页分享给需要快速检索站内内容的人。', description: '有人想少走弯路时,把这页递过去就行。',
examples: '示例问题', examples: '示例问题',
ai: 'AI', status: '状态',
statusValue: aiEnabled ? '随时开问' : '暂时休息',
}; };
const askHighlights = buildDiscoveryHighlights([ const askHighlights = buildDiscoveryHighlights([
t('ask.subtitle'), t('ask.subtitle'),
@@ -104,28 +107,41 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`} title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
description={t('ask.pageDescription', { siteName: siteSettings.siteName })} description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings} siteSettings={siteSettings}
jsonLd={[...askJsonLd, askFaqJsonLd].filter(Boolean)} jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])}
> >
<PageViewTracker pageType="ask" entityId="ask" /> <PageViewTracker pageType="ask" entityId="ask" />
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <section class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8">
<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"> <TerminalWindow title="~/ask" class="w-full">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4"> <div class="px-4 pb-2">
<div> <div class="terminal-panel ml-4 mt-4 space-y-6">
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div> <div class="flex flex-wrap items-start justify-between gap-4">
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1> <div class="space-y-3">
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p> <span class="terminal-kicker">
</div> <i class="fas fa-sparkles"></i>
<div class:list={[ {t('ask.terminalLabel')}
'rounded-full border px-3 py-1 text-xs font-mono', </span>
aiEnabled <div class="space-y-2">
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' <h1 class="text-2xl font-bold text-[var(--title-color)] sm:text-3xl">{t('ask.title')}</h1>
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300' <p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)] sm:text-base">
]}> {t('ask.subtitle')}
{aiEnabled ? t('common.featureOn') : t('common.featureOff')} </p>
</div> </div>
</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 <SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`} shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })} summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
@@ -136,13 +152,11 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
description={sharePanelCopy.description} description={sharePanelCopy.description}
stats={[ stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) }, { 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} wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/> />
</div>
<div class="px-5 pt-6">
<DiscoveryBrief <DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'} badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai" kicker="geo / ai"
@@ -151,20 +165,19 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
highlights={askHighlights} highlights={askHighlights}
faqs={askFaqs} faqs={askFaqs}
/> />
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]"> <div class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_18rem]">
<div class="min-w-0"> <div class="min-w-0 space-y-5">
{aiEnabled ? ( {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" /> <CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea <textarea
id="ai-question" 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')} placeholder={t('ask.textareaPlaceholder')}
></textarea> ></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"> <button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i> <i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span> <span>{t('ask.submit')}</span>
@@ -173,7 +186,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
</form> </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="flex items-center justify-between gap-3">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div> <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> <div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
@@ -183,7 +196,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </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> <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> <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)]"> <p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
@@ -194,7 +207,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
<aside class="space-y-4"> <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="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
{sampleQuestions.map((question) => ( {sampleQuestions.map((question) => (
@@ -209,7 +222,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
</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> <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)]"> <ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
<li>{t('ask.guide1')}</li> <li>{t('ask.guide1')}</li>
@@ -220,9 +233,255 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</aside> </aside>
</div> </div>
</div> </div>
</div>
</TerminalWindow>
</section> </section>
</BaseLayout> </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 && ( {aiEnabled && (
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}> <script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
const t = window.__termiTranslate; const t = window.__termiTranslate;
@@ -317,6 +576,71 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
return html.join(''); 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) { function setInteractiveState(isLoading) {
if (submit) { if (submit) {
submit.toggleAttribute('disabled', isLoading); submit.toggleAttribute('disabled', isLoading);
@@ -512,7 +836,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
setInteractiveState(true); setInteractiveState(true);
result.classList.remove('hidden'); result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`; answer.innerHTML = renderConnectingState(t('ask.connectingShort'));
sources.innerHTML = ''; sources.innerHTML = '';
meta.textContent = ''; meta.textContent = '';
updatePrompt(t('ask.promptSubmitting')); updatePrompt(t('ask.promptSubmitting'));

View File

@@ -20,33 +20,27 @@ const isEnglish = locale.startsWith('en');
let categories: Category[] = []; let categories: Category[] = [];
let posts: Post[] = []; let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS; let siteSettings = DEFAULT_SITE_SETTINGS;
let categoriesFailed = false;
try { const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
api.getCategories(), api.getCategories(),
api.getPosts(), api.getPosts(),
api.getSiteSettings(), api.getSiteSettings(),
]); ]);
if (categoriesResult.status === 'fulfilled') { if (categoriesResult.status === 'fulfilled') {
categories = categoriesResult.value; categories = categoriesResult.value;
} else { } else {
categoriesFailed = true;
console.error('Failed to fetch categories:', categoriesResult.reason); console.error('Failed to fetch categories:', categoriesResult.reason);
} }
if (postsResult.status === 'fulfilled') { if (postsResult.status === 'fulfilled') {
posts = postsResult.value; posts = postsResult.value;
} else { } else {
console.error('Failed to fetch category posts:', postsResult.reason); console.error('Failed to fetch category posts:', postsResult.reason);
} }
if (settingsResult.status === 'fulfilled') { if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value; siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch category detail data:', error);
} }
const requested = decodeURIComponent(slug || '').trim().toLowerCase(); const requested = decodeURIComponent(slug || '').trim().toLowerCase();
@@ -59,8 +53,8 @@ const category =
if (!category) { if (!category) {
return new Response(null, { return new Response(null, {
status: categoriesFailed ? 503 : 404, status: categoriesResult.status !== 'fulfilled' ? 503 : 404,
headers: categoriesFailed ? { 'Retry-After': '120' } : undefined, 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' import { createApiClient, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
function resolveFaviconTarget(requestUrl: URL, configured: string | undefined) { function resolveFaviconTarget(requestUrl: URL, configured: string | undefined) {
const fallbackTarget = '/favicon.svg' const fallbackTarget = '/favicon-default.ico'
const candidate = configured?.trim() const candidate = configured?.trim()
if (!candidate) { if (!candidate) {

View File

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

View File

@@ -13,7 +13,7 @@ import TechStackList from '../components/TechStackList.astro';
import { terminalConfig } from '../lib/config/terminal'; import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n'; import { formatReadTime, getI18n } from '../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../lib/seo'; import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList, compactJsonLd } from '../lib/seo';
import type { AppFriendLink } from '../lib/api/client'; import type { AppFriendLink } from '../lib/api/client';
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types'; import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils'; import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
@@ -119,6 +119,11 @@ const systemStats = [
{ label: t('common.categories'), value: String(categories.length) }, { label: t('common.categories'), value: String(categories.length) },
{ label: t('common.friends'), value: String(friendLinks.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 techStack = siteSettings.techStack.map(name => ({ name }));
const tagFrequency = new Map<string, number>(); const tagFrequency = new Map<string, number>();
@@ -153,8 +158,9 @@ const activeContentRange =
const popularRangeCards = contentRanges.flatMap((range) => const popularRangeCards = contentRanges.flatMap((range) =>
range.popularPosts range.popularPosts
.filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post)) .filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post))
.map((item) => ({ .map((item, index) => ({
rangeKey: range.key, rangeKey: range.key,
rank: index + 1,
item, item,
post: item.post, post: item.post,
})), })),
@@ -221,17 +227,6 @@ const discoverPrompt = hasActiveFilters
const postsPrompt = hasActiveFilters const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') }) ? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: t('home.promptPostsDefault', { count: previewCount }); : 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 siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const homeJsonLd = [ 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.', 'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
} }
: { : {
badge: '页', badge: '入口页',
title: '分享首页', title: '把首页甩出去',
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。', description: '不知道发什么时,先发这个入口。轻松、不剧透,还挺省心。',
}; };
const homeShareSummary = isEnglish
? 'A light entry point for curious visitors. Click around and let the rest reveal itself.'
: '这是一个适合顺手转发的小入口,先逛逛,细节留到点开再说。';
const homeSidebarCopy = isEnglish const homeSidebarCopy = isEnglish
? { ? {
quickLinks: 'Quick links', 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', popularTitle: 'Hot now',
popularDesc: 'Track the most-read content in the selected window.', popularDesc: 'Track the most-read content in the selected window.',
friendsTitle: 'Friend links', friendsTitle: 'Friend links',
@@ -275,15 +274,39 @@ const homeSidebarCopy = isEnglish
} }
: { : {
quickLinks: '快速入口', quickLinks: '快速入口',
quickLinksDesc: '常用入口收进侧栏,首页阅读流更清爽。', quickLinksDesc: '常用入口都放这儿,手别忙,点就行。',
quickLinksMore: '更多频道',
popularTitle: '最近热门', popularTitle: '最近热门',
popularDesc: '按当前时间窗口查看最受关注的内容。', popularDesc: '看看最近是谁在悄悄抢镜。',
friendsTitle: '友情链接', friendsTitle: '友情链接',
friendsDesc: '先看几个常访问的站点入口。', friendsDesc: '隔壁摊位也许也有好东西。',
statsTitle: '站点概览', statsTitle: '站点概览',
statsDesc: '快速看一下当前站点规模与内容状态。', statsDesc: '轻量围观一下站内气氛,不必知道太多。',
aiBriefTitle: '站点摘要', 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([ const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription, siteSettings.siteDescription,
siteSettings.heroSubtitle, siteSettings.heroSubtitle,
@@ -314,7 +337,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
siteSettings={siteSettings} siteSettings={siteSettings}
canonical="/" canonical="/"
noindex={hasActiveFilters} noindex={hasActiveFilters}
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)} jsonLd={compactJsonLd([...homeJsonLd, homeFaqJsonLd])}
> >
<PageViewTracker pageType="home" entityId="homepage" /> <PageViewTracker pageType="home" entityId="homepage" />
<div class="mx-auto max-w-[1480px] px-4 py-6 sm:px-6 lg:px-8"> <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"> <div class="mb-6 px-4">
<CommandPrompt command={t('home.promptWelcome')} /> <CommandPrompt command={t('home.promptWelcome')} />
<div class="ml-4 home-hero-shell"> <div class="ml-4 home-hero-shell home-hero-shell--panel">
<div class="min-w-0"> <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="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> <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>
<div class="home-hero-meta"> <div class="home-hero-meta">
@@ -355,13 +396,19 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
{apiError && ( {apiError && (
<div class="mb-8 px-4"> <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"> <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)]">
{apiError} <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> </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 class="min-w-0 space-y-6">
<div id="discover"> <div id="discover">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} /> <CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
@@ -586,22 +633,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
</div> </div>
<aside class="space-y-4 xl:sticky xl:top-24 xl:self-start"> <aside class="home-sidebar-stack">
<section class="terminal-panel space-y-4"> <section class="terminal-panel home-sidebar-card home-sidebar-card--quickmenu space-y-4 xl:sticky xl:top-24">
<div class="space-y-1"> <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> <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> <p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.quickLinksDesc}</p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2"> <div class="home-sidebar-grid">
{navLinks.map(link => ( {primaryQuickLinks.map(link => (
<a <a
href={link.href} 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> <i class={`fas ${link.icon} text-[11px]`}></i>
</span> </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> </a>
))} ))}
</div> </div>
@@ -609,7 +679,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<SharePanel <SharePanel
shareTitle={siteSettings.siteTitle} shareTitle={siteSettings.siteTitle}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription} summary={homeShareSummary}
canonicalUrl={siteBaseUrl} canonicalUrl={siteBaseUrl}
badge={homeShareCopy.badge} badge={homeShareCopy.badge}
title={homeShareCopy.title} title={homeShareCopy.title}
@@ -618,22 +688,26 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
variant="compact" 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 class="flex items-start justify-between gap-3">
<div> <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> <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> <p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.popularDesc}</p>
</div> </div>
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span> <span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
</div> </div>
<div class="home-popular-sortbar"> <div class="home-popular-rangebar">
{popularRangeOptions.map((option) => ( {popularRangeOptions.map((option) => (
<button <button
type="button" type="button"
data-home-popular-range={option.key} data-home-popular-range={option.key}
class:list={[ class:list={[
'home-popular-sort', 'home-popular-range',
option.key === activeContentRange.key && 'is-active' option.key === activeContentRange.key && 'is-active'
]} ]}
> >
@@ -644,7 +718,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
<div id="home-popular-list" class="space-y-3"> <div id="home-popular-list" class="space-y-3">
{popularRangeCards.map(({ rangeKey, item, post }) => ( {popularRangeCards.map(({ rangeKey, rank, item, post }) => (
<a <a
href={`/articles/${post.slug}`} href={`/articles/${post.slug}`}
data-home-popular-card data-home-popular-card
@@ -655,16 +729,15 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
data-home-slug={post.slug} data-home-slug={post.slug}
data-home-popular-views={item.pageViews} data-home-popular-views={item.pageViews}
data-home-popular-completes={item.readCompletes} data-home-popular-completes={item.readCompletes}
data-home-popular-depth={Math.round(item.avgProgressPercent)}
class:list={[ 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' !initialPopularVisibleKeys.has(`${rangeKey}:${post.slug}`) && 'hidden'
]} ]}
style={getAccentVars(getPostTypeTheme(post.type))} style={getAccentVars(getPostTypeTheme(post.type))}
> >
<div class="flex items-start gap-3"> <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)]"> <span class="home-sidebar-popular__rank">
{item.pageViews} <span data-home-popular-rank-label>{rank}</span>
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -676,7 +749,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</span> </span>
</div> </div>
<h4 class="mt-2 line-clamp-2 text-sm font-semibold leading-6 text-[var(--title-color)]">{post.title}</h4> <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.views')}: {item.pageViews}</span>
<span>{t('home.completes')}: {item.readCompletes}</span> <span>{t('home.completes')}: {item.readCompletes}</span>
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span> <span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
@@ -692,9 +765,13 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
</section> </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 class="flex items-start justify-between gap-3">
<div> <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> <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> <p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.statsDesc}</p>
</div> </div>
@@ -702,41 +779,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2"> <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 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 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> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
</div> </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 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 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> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
</div> </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 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-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> <p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
</div> </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 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 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> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p>
</div> </div>
</div> </div>
<div class="grid gap-3 sm:grid-cols-2"> <div class="home-sidebar-meta-list">
{systemStats.slice(0, 4).map((item) => ( {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="home-sidebar-meta-item">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div> <span class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</span>
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div> <span class="text-sm font-semibold text-[var(--title-color)]">{item.value}</span>
</div> </div>
))} ))}
</div> </div>
</section> </section>
{sidebarFriendLinks.length > 0 && ( {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"> <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> <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> <p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.friendsDesc}</p>
</div> </div>
@@ -747,7 +828,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
href={friend.url} href={friend.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 ? ( {friend.avatar ? (
<img <img
@@ -790,26 +871,61 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<div id="about" class="px-4"> <div id="about" class="px-4">
<CommandPrompt command={t('home.promptAbout')} /> <CommandPrompt command={t('home.promptAbout')} />
<div class="ml-4"> <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> <div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3> <h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.about')}</h3>
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p> <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} /> <TechStackList items={techStack} />
</div> </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> <div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3> <h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.systemStatus')}</h3>
<StatsList stats={systemStats} /> <p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{homeAboutSectionCopy.metricsDesc}</p>
</div> </div>
</div> </div>
<StatsList stats={systemStats} />
</section>
</div>
</div> </div>
</div> </div>
<div class="my-8 border-t border-[var(--border-color)]"></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"> <div class="ml-4">
<DiscoveryBrief <DiscoveryBrief
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle} badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}
@@ -874,7 +990,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
const popularList = document.getElementById('home-popular-list'); const popularList = document.getElementById('home-popular-list');
const popularCount = document.getElementById('home-popular-count'); const popularCount = document.getElementById('home-popular-count');
const popularRangeButtons = Array.from(document.querySelectorAll('[data-home-popular-range]')); 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 readingWindowPill = document.getElementById('home-stats-window-pill');
const readingViewsValue = document.getElementById('home-reading-views-value'); const readingViewsValue = document.getElementById('home-reading-views-value');
const readingCompletesValue = document.getElementById('home-reading-completes-value'); const readingCompletesValue = document.getElementById('home-reading-completes-value');
@@ -902,7 +1017,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
category: initialHomeState.category || '', category: initialHomeState.category || '',
tag: initialHomeState.tag || '', tag: initialHomeState.tag || '',
range: initialHomeState.range || '7d', range: initialHomeState.range || '7d',
popularSort: 'views',
}; };
function getActiveRange() { 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) { 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) => { 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 leftViews = Number(left.getAttribute('data-home-popular-views') || '0');
const rightViews = Number(right.getAttribute('data-home-popular-views') || '0'); const rightViews = Number(right.getAttribute('data-home-popular-views') || '0');
if (rightViews !== leftViews) { if (rightViews !== leftViews) {
return 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( return String(left.getAttribute('data-home-slug') || '').localeCompare(
String(right.getAttribute('data-home-slug') || '') String(right.getAttribute('data-home-slug') || '')
); );
@@ -975,18 +1072,14 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
function syncPromptText(total) { function syncPromptText(total) {
const tokens = getActiveTokens(); const tokens = getActiveTokens();
const activeRange = getActiveRange();
const discoverCommand = tokens.length const discoverCommand = tokens.length
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') }) ? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
: t('home.promptDiscoverDefault'); : t('home.promptDiscoverDefault');
const postsCommand = tokens.length const postsCommand = tokens.length
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') }) ? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) }); : 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-discover-prompt', discoverCommand, { typing: false });
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false }); promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
promptApi?.set?.('home-popular-prompt', popularCommand, { typing: false });
} }
function syncRangeMetrics(filteredPopularCount) { function syncRangeMetrics(filteredPopularCount) {
@@ -1144,8 +1237,12 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
}); });
const sortedPopular = sortPopularCards(filteredPopular); const sortedPopular = sortPopularCards(filteredPopular);
popularCards.forEach((card) => card.classList.add('hidden')); popularCards.forEach((card) => card.classList.add('hidden'));
sortedPopular.slice(0, popularPreviewLimit).forEach((card) => { sortedPopular.slice(0, popularPreviewLimit).forEach((card, index) => {
card.classList.remove('hidden'); card.classList.remove('hidden');
const rankLabel = card.querySelector('[data-home-popular-rank-label]');
if (rankLabel) {
rankLabel.textContent = String(index + 1);
}
popularList?.appendChild(card); popularList?.appendChild(card);
}); });
@@ -1172,7 +1269,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
syncActiveSummary(); syncActiveSummary();
syncPostTagSelection(); syncPostTagSelection();
syncPopularRangeButtons(); syncPopularRangeButtons();
syncPopularSortButtons();
syncRangeMetrics(filteredPopular.length); syncRangeMetrics(filteredPopular.length);
syncPromptText(total); 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) => { postsRoot?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null; const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
if (!target) return; if (!target) return;

View File

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

View File

@@ -20,33 +20,27 @@ const isEnglish = locale.startsWith('en');
let tags: Tag[] = []; let tags: Tag[] = [];
let posts: Post[] = []; let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS; let siteSettings = DEFAULT_SITE_SETTINGS;
let tagsFailed = false;
try { const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
apiClient.getTags(), apiClient.getTags(),
apiClient.getPosts(), apiClient.getPosts(),
apiClient.getSiteSettings(), apiClient.getSiteSettings(),
]); ]);
if (tagsResult.status === 'fulfilled') { if (tagsResult.status === 'fulfilled') {
tags = tagsResult.value; tags = tagsResult.value;
} else { } else {
tagsFailed = true;
console.error('Failed to fetch tags:', tagsResult.reason); console.error('Failed to fetch tags:', tagsResult.reason);
} }
if (postsResult.status === 'fulfilled') { if (postsResult.status === 'fulfilled') {
posts = postsResult.value; posts = postsResult.value;
} else { } else {
console.error('Failed to fetch tag posts:', postsResult.reason); console.error('Failed to fetch tag posts:', postsResult.reason);
} }
if (settingsResult.status === 'fulfilled') { if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value; siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch tag detail data:', error);
} }
const requested = decodeURIComponent(slug || '').trim().toLowerCase(); const requested = decodeURIComponent(slug || '').trim().toLowerCase();
@@ -59,8 +53,8 @@ const tag =
if (!tag) { if (!tag) {
return new Response(null, { return new Response(null, {
status: tagsFailed ? 503 : 404, status: tagsResult.status !== 'fulfilled' ? 503 : 404,
headers: tagsFailed ? { 'Retry-After': '120' } : undefined, 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 () => { async () => {
const data = await requestBackend('POST', '/ai/reindex'); 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();
}
});