Compare commits
11 Commits
0f2342a713
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4f027062 | |||
| 646a32f207 | |||
| 381dc9b854 | |||
| ab18bbaf23 | |||
| d065e3da88 | |||
| 11ec00281c | |||
| 320595ee1c | |||
| ad44dde886 | |||
| 99a57738e0 | |||
| cf00dc5e8e | |||
| 1df179c327 |
@@ -1,21 +1,6 @@
|
||||
name: ui-regression
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- admin/**
|
||||
- frontend/**
|
||||
- playwright-smoke/**
|
||||
- .gitea/workflows/ui-regression.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- admin/**
|
||||
- frontend/**
|
||||
- playwright-smoke/**
|
||||
- .gitea/workflows/ui-regression.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -35,6 +20,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
frontend/pnpm-lock.yaml
|
||||
admin/pnpm-lock.yaml
|
||||
playwright-smoke/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
@@ -48,7 +38,15 @@ jobs:
|
||||
working-directory: playwright-smoke
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: playwright-smoke
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
@@ -69,71 +67,22 @@ jobs:
|
||||
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare Playwright artifact folders
|
||||
run: |
|
||||
rm -rf playwright-smoke/.artifacts
|
||||
mkdir -p playwright-smoke/.artifacts/frontend
|
||||
mkdir -p playwright-smoke/.artifacts/admin
|
||||
|
||||
- name: Run frontend UI regression suite
|
||||
id: ui_frontend
|
||||
working-directory: playwright-smoke
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
||||
PLAYWRIGHT_USE_BUILT_APP: "1"
|
||||
run: pnpm test:frontend
|
||||
|
||||
- name: Collect frontend Playwright artifacts
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d playwright-smoke/playwright-report ]; then
|
||||
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
|
||||
fi
|
||||
if [ -d playwright-smoke/test-results ]; then
|
||||
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
|
||||
fi
|
||||
rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
|
||||
|
||||
- name: Run admin UI regression suite
|
||||
id: ui_admin
|
||||
working-directory: playwright-smoke
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
||||
PLAYWRIGHT_USE_BUILT_APP: "1"
|
||||
run: pnpm test:admin
|
||||
|
||||
- name: Collect admin Playwright artifacts
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d playwright-smoke/playwright-report ]; then
|
||||
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
|
||||
fi
|
||||
if [ -d playwright-smoke/test-results ]; then
|
||||
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
|
||||
fi
|
||||
|
||||
- name: Summarize Playwright artifact paths
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Gitea Actions 当前不支持 actions/upload-artifact@v4,改为直接输出产物目录:"
|
||||
|
||||
for path in \
|
||||
"playwright-smoke/.artifacts/frontend/playwright-report" \
|
||||
"playwright-smoke/.artifacts/frontend/test-results" \
|
||||
"playwright-smoke/.artifacts/admin/playwright-report" \
|
||||
"playwright-smoke/.artifacts/admin/test-results"
|
||||
do
|
||||
if [ -d "${path}" ]; then
|
||||
echo "- ${path}"
|
||||
find "${path}" -maxdepth 2 -type f | sort | head -n 20
|
||||
else
|
||||
echo "- ${path} (missing)"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Mark workflow failed when any suite failed
|
||||
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
|
||||
run: exit 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
.codex/
|
||||
.codex-tmp/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
.windsurf/
|
||||
|
||||
|
||||
@@ -89,9 +89,11 @@ pnpm dev
|
||||
```powershell
|
||||
cd backend
|
||||
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
||||
cargo loco start 2>&1
|
||||
cargo loco start --server-and-worker 2>&1
|
||||
```
|
||||
|
||||
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`。
|
||||
|
||||
### Docker(生产部署,使用 Gitea Package 镜像)
|
||||
|
||||
补充部署分层与反代说明见:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AdminAnalyticsResponse,
|
||||
AdminAiImageProviderTestResponse,
|
||||
AdminAiReindexResponse,
|
||||
AdminAiProviderTestResponse,
|
||||
AdminImageUploadResponse,
|
||||
AdminMediaBatchDeleteResponse,
|
||||
@@ -362,7 +361,7 @@ export const adminApi = {
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
reindexAi: () =>
|
||||
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
||||
request<WorkerTaskActionResponse>('/api/admin/ai/reindex', {
|
||||
method: 'POST',
|
||||
}),
|
||||
testAiProvider: (provider: {
|
||||
|
||||
@@ -545,11 +545,6 @@ export interface TaxonomyPayload {
|
||||
seoDescription?: string | null
|
||||
}
|
||||
|
||||
export interface AdminAiReindexResponse {
|
||||
indexed_chunks: number
|
||||
last_indexed_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminAiProviderTestResponse {
|
||||
provider: string
|
||||
endpoint: string
|
||||
|
||||
127
admin/src/lib/worker-progress.ts
Normal file
127
admin/src/lib/worker-progress.ts
Normal 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)));
|
||||
}
|
||||
@@ -10,15 +10,22 @@ import {
|
||||
Star,
|
||||
Tags,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
} from "lucide-react";
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTime } from "@/lib/admin-format";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,9 +33,9 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
} from "@/components/ui/table";
|
||||
import { adminApi, ApiError } from "@/lib/api";
|
||||
import { buildFrontendUrl } from "@/lib/frontend-url";
|
||||
import {
|
||||
formatCommentScope,
|
||||
formatPostStatus,
|
||||
@@ -37,8 +44,12 @@ import {
|
||||
formatPostVisibility,
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
} from '@/lib/admin-format'
|
||||
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
||||
} from "@/lib/admin-format";
|
||||
import type {
|
||||
AdminAnalyticsResponse,
|
||||
AdminDashboardResponse,
|
||||
WorkerOverview,
|
||||
} from "@/lib/types";
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -46,17 +57,21 @@ function StatCard({
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
note: string
|
||||
icon: typeof Rss
|
||||
label: string;
|
||||
value: number;
|
||||
note: string;
|
||||
icon: typeof Rss;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="flex items-start justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">
|
||||
{value}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
@@ -64,75 +79,81 @@ function StatCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatAiSourceLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'chatgpt-search':
|
||||
return 'ChatGPT Search'
|
||||
case 'perplexity':
|
||||
return 'Perplexity'
|
||||
case 'copilot-bing':
|
||||
return 'Copilot / Bing'
|
||||
case 'gemini':
|
||||
return 'Gemini'
|
||||
case 'claude':
|
||||
return 'Claude'
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'duckduckgo':
|
||||
return 'DuckDuckGo'
|
||||
case 'kagi':
|
||||
return 'Kagi'
|
||||
case 'direct':
|
||||
return 'Direct'
|
||||
case "chatgpt-search":
|
||||
return "ChatGPT Search";
|
||||
case "perplexity":
|
||||
return "Perplexity";
|
||||
case "copilot-bing":
|
||||
return "Copilot / Bing";
|
||||
case "gemini":
|
||||
return "Gemini";
|
||||
case "claude":
|
||||
return "Claude";
|
||||
case "google":
|
||||
return "Google";
|
||||
case "duckduckgo":
|
||||
return "DuckDuckGo";
|
||||
case "kagi":
|
||||
return "Kagi";
|
||||
case "direct":
|
||||
return "Direct";
|
||||
default:
|
||||
return value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [data, setData] = useState<AdminDashboardResponse | null>(null);
|
||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
|
||||
null,
|
||||
);
|
||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
||||
adminApi.dashboard(),
|
||||
adminApi.getWorkersOverview(),
|
||||
adminApi.analytics(),
|
||||
])
|
||||
]);
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
setWorkerOverview(nextWorkerOverview)
|
||||
setAnalytics(nextAnalytics)
|
||||
})
|
||||
setData(next);
|
||||
setWorkerOverview(nextWorkerOverview);
|
||||
setAnalytics(nextAnalytics);
|
||||
});
|
||||
|
||||
if (showToast) {
|
||||
toast.success('仪表盘已刷新。')
|
||||
toast.success("仪表盘已刷新。");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : "无法加载仪表盘。",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard(false)
|
||||
}, [loadDashboard])
|
||||
void loadDashboard(false);
|
||||
}, [loadDashboard]);
|
||||
|
||||
if (loading || !data || !workerOverview || !analytics) {
|
||||
return (
|
||||
@@ -147,24 +168,24 @@ export function DashboardPage() {
|
||||
<Skeleton className="h-[420px] rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: '文章总数',
|
||||
label: "文章总数",
|
||||
value: data.stats.total_posts,
|
||||
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||
icon: Rss,
|
||||
},
|
||||
{
|
||||
label: '待审核评论',
|
||||
label: "待审核评论",
|
||||
value: data.stats.pending_comments,
|
||||
note: '等待审核处理',
|
||||
note: "等待审核处理",
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: '发布待办',
|
||||
label: "发布待办",
|
||||
value:
|
||||
data.stats.draft_posts +
|
||||
data.stats.scheduled_posts +
|
||||
@@ -174,30 +195,32 @@ export function DashboardPage() {
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: '分类数量',
|
||||
label: "分类数量",
|
||||
value: data.stats.total_categories,
|
||||
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
label: 'AI 分块',
|
||||
label: "AI 分块",
|
||||
value: data.stats.ai_chunks,
|
||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||
note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
{
|
||||
label: 'Worker 活动',
|
||||
label: "Worker 活动",
|
||||
value: workerOverview.active_jobs,
|
||||
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
|
||||
icon: Workflow,
|
||||
},
|
||||
]
|
||||
];
|
||||
const aiTrafficShare =
|
||||
analytics.content_overview.page_views_last_7d > 0
|
||||
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100
|
||||
: 0
|
||||
const topAiSource = analytics.ai_referrers_last_7d[0]
|
||||
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length
|
||||
? (analytics.ai_discovery_page_views_last_7d /
|
||||
analytics.content_overview.page_views_last_7d) *
|
||||
100
|
||||
: 0;
|
||||
const topAiSource = analytics.ai_referrers_last_7d[0];
|
||||
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -207,14 +230,15 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">运营总览</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。
|
||||
这里汇总了最重要的发布、审核和 AI
|
||||
信号,让日常运营在一个独立后台里完成闭环。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
打开 AI 问答
|
||||
</a>
|
||||
@@ -225,7 +249,7 @@ export function DashboardPage() {
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
{refreshing ? "刷新中..." : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,9 +265,7 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近文章</CardTitle>
|
||||
<CardDescription>
|
||||
最近同步到前台的文章内容。
|
||||
</CardDescription>
|
||||
<CardDescription>最近同步到前台的文章内容。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||
</CardHeader>
|
||||
@@ -265,9 +287,13 @@ export function DashboardPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{post.title}</span>
|
||||
{post.pinned ? <Badge variant="success">置顶</Badge> : null}
|
||||
{post.pinned ? (
|
||||
<Badge variant="success">置顶</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{post.slug}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
@@ -275,12 +301,18 @@ export function DashboardPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
|
||||
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{formatPostStatus(post.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{formatPostVisibility(post.visibility)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{post.category}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{post.created_at}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -291,19 +323,19 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点状态</CardTitle>
|
||||
<CardDescription>
|
||||
快速查看前台站点与 AI 索引状态。
|
||||
</CardDescription>
|
||||
<CardDescription>快速查看前台站点与 AI 索引状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.site.site_name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data.site.site_url}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
||||
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
||||
<Badge variant={data.site.ai_enabled ? "success" : "warning"}>
|
||||
{data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +346,9 @@ export function DashboardPage() {
|
||||
评测
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||
<span className="text-3xl font-semibold">
|
||||
{data.stats.total_reviews}
|
||||
</span>
|
||||
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +357,9 @@ export function DashboardPage() {
|
||||
友链
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
||||
<span className="text-3xl font-semibold">
|
||||
{data.stats.total_links}
|
||||
</span>
|
||||
<Tags className="mb-1 h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,25 +371,35 @@ export function DashboardPage() {
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{data.stats.draft_posts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">草稿</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{data.stats.scheduled_posts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">定时发布</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{data.stats.offline_posts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">手动下线</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{data.stats.expired_posts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">自动过期</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">私有 {data.stats.private_posts}</Badge>
|
||||
<Badge variant="outline">不公开 {data.stats.unlisted_posts}</Badge>
|
||||
<Badge variant="outline">
|
||||
不公开 {data.stats.unlisted_posts}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -362,7 +408,9 @@ export function DashboardPage() {
|
||||
最近一次 AI 索引
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
||||
{data.site.ai_last_indexed_at
|
||||
? formatDateTime(data.site.ai_last_indexed_at)
|
||||
: "站点还没有建立过索引。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -372,9 +420,13 @@ export function DashboardPage() {
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
GEO / AI 来源概览
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{analytics.ai_discovery_page_views_last_7d}</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{analytics.ai_discovery_page_views_last_7d}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
|
||||
最近 7 天来自 ChatGPT
|
||||
Search、Perplexity、Copilot/Bing、Gemini、Claude
|
||||
的页面访问。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
@@ -384,23 +436,41 @@ export function DashboardPage() {
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">访问占比</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">基于近 7 天全部 page_view</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
访问占比
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">最高来源</div>
|
||||
<div className="mt-2 text-base font-semibold">
|
||||
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
|
||||
<div className="mt-2 text-2xl font-semibold">
|
||||
{Math.round(aiTrafficShare)}%
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
|
||||
基于近 7 天全部 page_view
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">已识别来源</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">当前已聚合的 AI 搜索渠道</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最高来源
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold">
|
||||
{topAiSource
|
||||
? formatAiSourceLabel(topAiSource.referrer)
|
||||
: "暂无"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{topAiSource
|
||||
? `${topAiSource.count} 次访问`
|
||||
: "等待来源数据"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
已识别来源
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">
|
||||
{totalAiSourceBuckets}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
当前已聚合的 AI 搜索渠道
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,15 +478,24 @@ export function DashboardPage() {
|
||||
<div className="mt-4 space-y-3">
|
||||
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
|
||||
const width = `${Math.max(
|
||||
(item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100,
|
||||
(item.count /
|
||||
Math.max(
|
||||
analytics.ai_discovery_page_views_last_7d,
|
||||
1,
|
||||
)) *
|
||||
100,
|
||||
8,
|
||||
)}%`
|
||||
)}%`;
|
||||
|
||||
return (
|
||||
<div key={item.referrer} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span>
|
||||
<span className="text-muted-foreground">{item.count}</span>
|
||||
<span className="font-medium">
|
||||
{formatAiSourceLabel(item.referrer)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
@@ -425,11 +504,13 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-muted-foreground">最近 7 天还没有识别到 AI 搜索来源流量。</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
最近 7 天还没有识别到 AI 搜索来源流量。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -440,12 +521,17 @@ export function DashboardPage() {
|
||||
Worker 健康
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。
|
||||
当前排队 {workerOverview.queued}、运行{" "}
|
||||
{workerOverview.running}、失败 {workerOverview.failed}。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
|
||||
to={
|
||||
workerOverview.failed > 0
|
||||
? "/workers?status=failed"
|
||||
: "/workers"
|
||||
}
|
||||
data-testid="dashboard-worker-open"
|
||||
>
|
||||
查看队列
|
||||
@@ -459,24 +545,36 @@ export function DashboardPage() {
|
||||
data-testid="dashboard-worker-card-queued"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Queued
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.queued}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=running"
|
||||
data-testid="dashboard-worker-card-running"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Running
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.running}
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/workers?status=failed"
|
||||
data-testid="dashboard-worker-card-failed"
|
||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Failed
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||
{workerOverview.failed}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -489,11 +587,17 @@ export function DashboardPage() {
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.worker_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<div>Q {item.queued} · R {item.running}</div>
|
||||
<div>
|
||||
Q {item.queued} · R {item.running}
|
||||
</div>
|
||||
<div>ERR {item.failed}</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -510,11 +614,11 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>待审核评论</CardTitle>
|
||||
<CardDescription>
|
||||
在当前管理端直接查看审核队列。
|
||||
</CardDescription>
|
||||
<CardDescription>在当前管理端直接查看审核队列。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||
<Badge variant="warning">
|
||||
{data.pending_comments.length} 条待处理
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -543,7 +647,9 @@ export function DashboardPage() {
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{comment.post_slug}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{comment.created_at}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -556,11 +662,11 @@ export function DashboardPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>待审核友链</CardTitle>
|
||||
<CardDescription>
|
||||
等待审核和互链确认的申请。
|
||||
</CardDescription>
|
||||
<CardDescription>等待审核和互链确认的申请。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
||||
<Badge variant="warning">
|
||||
{data.pending_friend_links.length} 条待处理
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.pending_friend_links.map((link) => (
|
||||
@@ -591,9 +697,7 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近评测</CardTitle>
|
||||
<CardDescription>
|
||||
最近同步到前台评测页的内容。
|
||||
</CardDescription>
|
||||
<CardDescription>最近同步到前台评测页的内容。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.recent_reviews.map((review) => (
|
||||
@@ -604,11 +708,14 @@ export function DashboardPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
|
||||
{formatReviewType(review.review_type)} ·{" "}
|
||||
{formatReviewStatus(review.status)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold">{review.rating}/5</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{review.rating}/5
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{review.review_date}
|
||||
</p>
|
||||
@@ -620,5 +727,5 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,15 @@ Loco.rs backend,当前仅保留 API 与后台鉴权相关逻辑,不再提供
|
||||
## 本地启动
|
||||
|
||||
```powershell
|
||||
cargo loco start
|
||||
cargo loco start --server-and-worker
|
||||
```
|
||||
|
||||
默认本地监听:
|
||||
|
||||
- `http://localhost:5150`
|
||||
|
||||
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
|
||||
|
||||
## 当前职责
|
||||
|
||||
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 技术笔记与内容档案"
|
||||
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
|
||||
hero_title: "欢迎来到 InitCool"
|
||||
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
|
||||
site_description: "一个认真折腾、偶尔整活的小站。"
|
||||
hero_title: "欢迎光临,先随便翻翻"
|
||||
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
owner_title: "负责把脑洞拧成页面的人"
|
||||
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
|
||||
owner_avatar_url: "https://github.com/limitcool.png"
|
||||
social_github: "https://github.com/limitcool"
|
||||
social_twitter: ""
|
||||
|
||||
@@ -28,7 +28,10 @@ use crate::{
|
||||
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
||||
},
|
||||
tasks,
|
||||
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
|
||||
workers::{
|
||||
ai_reindex::AiReindexWorker, downloader::DownloadWorker,
|
||||
notification_delivery::NotificationDeliveryWorker,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
@@ -153,6 +156,7 @@ impl Hooks for App {
|
||||
Ok(router.layer(cors))
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(AiReindexWorker::build(ctx)).await?;
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
queue
|
||||
.register(NotificationDeliveryWorker::build(ctx))
|
||||
|
||||
@@ -230,8 +230,8 @@ pub struct AdminSiteSettingsResponse {
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminAiReindexResponse {
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<String>,
|
||||
pub queued: bool,
|
||||
pub job: worker_jobs::WorkerJobRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -1395,16 +1395,28 @@ pub async fn update_site_settings(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let summary = ai::rebuild_index(&ctx).await?;
|
||||
let actor = check_auth(&headers)?;
|
||||
let job = worker_jobs::queue_ai_reindex_job(
|
||||
&ctx,
|
||||
Some(actor.username.clone()),
|
||||
Some(actor.source.clone()),
|
||||
None,
|
||||
Some("manual".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(AdminAiReindexResponse {
|
||||
indexed_chunks: summary.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(
|
||||
summary.last_indexed_at.map(Into::into),
|
||||
"%Y-%m-%d %H:%M:%S UTC",
|
||||
),
|
||||
})
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"worker.ai_reindex",
|
||||
"worker_job",
|
||||
Some(job.id.to_string()),
|
||||
Some(job.worker_name.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(AdminAiReindexResponse { queued: true, job })
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
controllers::{admin::check_auth, site_settings},
|
||||
services::{abuse_guard, ai, analytics},
|
||||
services::{abuse_guard, ai, analytics, worker_jobs},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -35,8 +35,8 @@ pub struct AskResponse {
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReindexResponse {
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<String>,
|
||||
pub queued: bool,
|
||||
pub job: worker_jobs::WorkerJobRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -514,13 +514,17 @@ pub async fn ask_stream(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let summary = ai::rebuild_index(&ctx).await?;
|
||||
let actor = check_auth(&headers)?;
|
||||
let job = worker_jobs::queue_ai_reindex_job(
|
||||
&ctx,
|
||||
Some(actor.username.clone()),
|
||||
Some(actor.source.clone()),
|
||||
None,
|
||||
Some("manual".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(ReindexResponse {
|
||||
indexed_chunks: summary.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(summary.last_indexed_at),
|
||||
})
|
||||
format::json(ReindexResponse { queued: true, job })
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 技术笔记与内容档案"
|
||||
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
|
||||
hero_title: "欢迎来到 InitCool"
|
||||
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
|
||||
site_description: "一个认真折腾、偶尔整活的小站。"
|
||||
hero_title: "欢迎光临,先随便翻翻"
|
||||
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
owner_title: "负责把脑洞拧成页面的人"
|
||||
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
|
||||
owner_avatar_url: "https://github.com/limitcool.png"
|
||||
social_github: "https://github.com/limitcool"
|
||||
social_twitter: ""
|
||||
|
||||
@@ -7,7 +7,7 @@ use loco_rs::prelude::*;
|
||||
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||
PaginatorTrait, QueryOrder, Set, Statement,
|
||||
PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -19,7 +19,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
controllers::site_settings as site_settings_controller,
|
||||
models::_entities::{ai_chunks, site_settings},
|
||||
services::{content, storage},
|
||||
services::{content, storage, worker_jobs},
|
||||
};
|
||||
|
||||
const DEFAULT_AI_PROVIDER: &str = "openai";
|
||||
@@ -36,6 +36,7 @@ const DEFAULT_TOP_K: usize = 4;
|
||||
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
||||
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
|
||||
const EMBEDDING_BATCH_SIZE: usize = 32;
|
||||
pub(crate) const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
|
||||
const EMBEDDING_DIMENSION: usize = 384;
|
||||
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
|
||||
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
|
||||
@@ -202,6 +203,18 @@ pub struct AiIndexSummary {
|
||||
pub last_indexed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct AiReindexProgress {
|
||||
phase: String,
|
||||
message: String,
|
||||
total_chunks: usize,
|
||||
processed_chunks: usize,
|
||||
total_batches: usize,
|
||||
current_batch: usize,
|
||||
batch_size: usize,
|
||||
percent: usize,
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
@@ -771,6 +784,14 @@ pub fn default_image_model_for_provider(provider: &str) -> &'static str {
|
||||
}
|
||||
|
||||
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
||||
embed_texts_locally_with_batch_size(inputs, kind, EMBEDDING_BATCH_SIZE).await
|
||||
}
|
||||
|
||||
async fn embed_texts_locally_with_batch_size(
|
||||
inputs: Vec<String>,
|
||||
kind: EmbeddingKind,
|
||||
batch_size: usize,
|
||||
) -> Result<Vec<Vec<f64>>> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = local_embedding_engine()?;
|
||||
let prepared = inputs
|
||||
@@ -783,7 +804,7 @@ async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result
|
||||
})?;
|
||||
|
||||
let embeddings = guard
|
||||
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
|
||||
.embed(prepared, Some(batch_size.max(1)))
|
||||
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
|
||||
|
||||
Ok(embeddings
|
||||
@@ -2461,6 +2482,73 @@ fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_reindex_progress(
|
||||
phase: &str,
|
||||
message: String,
|
||||
total_chunks: usize,
|
||||
processed_chunks: usize,
|
||||
batch_size: usize,
|
||||
) -> AiReindexProgress {
|
||||
let normalized_batch_size = batch_size.max(1);
|
||||
let total_batches = total_chunks.div_ceil(normalized_batch_size);
|
||||
let current_batch = if processed_chunks == 0 {
|
||||
0
|
||||
} else {
|
||||
processed_chunks
|
||||
.div_ceil(normalized_batch_size)
|
||||
.min(total_batches)
|
||||
};
|
||||
let percent = if total_chunks == 0 {
|
||||
100
|
||||
} else {
|
||||
((processed_chunks * 100) / total_chunks).min(100)
|
||||
};
|
||||
|
||||
AiReindexProgress {
|
||||
phase: phase.to_string(),
|
||||
message,
|
||||
total_chunks,
|
||||
processed_chunks,
|
||||
total_batches,
|
||||
current_batch,
|
||||
batch_size: normalized_batch_size,
|
||||
percent,
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_reindex_job_progress(
|
||||
ctx: &AppContext,
|
||||
job_id: Option<i32>,
|
||||
progress: &AiReindexProgress,
|
||||
) -> Result<()> {
|
||||
let Some(job_id) = job_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
worker_jobs::update_job_result(
|
||||
ctx,
|
||||
job_id,
|
||||
serde_json::json!({
|
||||
"phase": progress.phase,
|
||||
"message": progress.message,
|
||||
"progress": progress,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
|
||||
let Some(job_id) = job_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
|
||||
return Err(Error::BadRequest("job cancelled".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_runtime_settings(
|
||||
ctx: &AppContext,
|
||||
require_enabled: bool,
|
||||
@@ -2555,14 +2643,14 @@ async fn load_runtime_settings(
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_indexed_at(
|
||||
ctx: &AppContext,
|
||||
async fn update_indexed_at<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
settings: &site_settings::Model,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
let now = Utc::now();
|
||||
let mut model = settings.clone().into_active_model();
|
||||
model.ai_last_indexed_at = Set(Some(now.into()));
|
||||
let _ = model.update(&ctx.db).await?;
|
||||
let _ = model.update(db).await?;
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
@@ -2571,14 +2659,8 @@ async fn retrieve_matches(
|
||||
settings: &AiRuntimeSettings,
|
||||
question: &str,
|
||||
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
|
||||
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
||||
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
||||
|
||||
if indexed_chunks == 0 {
|
||||
let summary = rebuild_index(ctx).await?;
|
||||
indexed_chunks = summary.indexed_chunks;
|
||||
last_indexed_at = summary.last_indexed_at;
|
||||
}
|
||||
let indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
||||
let last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
||||
|
||||
if indexed_chunks == 0 {
|
||||
return Ok((Vec::new(), 0, last_indexed_at));
|
||||
@@ -2640,32 +2722,49 @@ async fn retrieve_matches(
|
||||
Ok((matches, indexed_chunks, last_indexed_at))
|
||||
}
|
||||
|
||||
pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
||||
pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIndexSummary> {
|
||||
let settings = load_runtime_settings(ctx, false).await?;
|
||||
let posts = content::load_markdown_posts_from_store(ctx).await?;
|
||||
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
|
||||
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
|
||||
let embeddings = if chunk_drafts.is_empty() {
|
||||
Vec::new()
|
||||
let total_chunks = chunk_drafts.len();
|
||||
let batch_size = REINDEX_EMBEDDING_BATCH_SIZE.max(1);
|
||||
let preparing_progress = build_reindex_progress(
|
||||
"preparing",
|
||||
if total_chunks == 0 {
|
||||
"没有可写入的内容,正在清理旧索引。".to_string()
|
||||
} else {
|
||||
embed_texts_locally(
|
||||
chunk_drafts
|
||||
.iter()
|
||||
.map(|chunk| chunk.content.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
EmbeddingKind::Passage,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
format!("已收集 {total_chunks} 个分块,准备重建向量索引。")
|
||||
},
|
||||
total_chunks,
|
||||
0,
|
||||
batch_size,
|
||||
);
|
||||
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
|
||||
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||
let txn = ctx.db.begin().await?;
|
||||
|
||||
ctx.db
|
||||
.execute(Statement::from_string(
|
||||
txn.execute(Statement::from_string(
|
||||
DbBackend::Postgres,
|
||||
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
|
||||
let mut processed_chunks = 0usize;
|
||||
|
||||
for chunk_batch in chunk_drafts.chunks(batch_size) {
|
||||
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||
let embeddings = embed_texts_locally_with_batch_size(
|
||||
chunk_batch
|
||||
.iter()
|
||||
.map(|chunk| chunk.content.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
EmbeddingKind::Passage,
|
||||
batch_size,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for (draft, embedding) in chunk_batch.iter().zip(embeddings.into_iter()) {
|
||||
let embedding_literal = vector_literal(&embedding)?;
|
||||
let statement = Statement::from_sql_and_values(
|
||||
DbBackend::Postgres,
|
||||
@@ -2696,10 +2795,27 @@ pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
||||
draft.word_count.into(),
|
||||
],
|
||||
);
|
||||
ctx.db.execute(statement).await?;
|
||||
txn.execute(statement).await?;
|
||||
}
|
||||
|
||||
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
|
||||
processed_chunks += chunk_batch.len();
|
||||
let embedding_progress = build_reindex_progress(
|
||||
"embedding",
|
||||
format!(
|
||||
"正在写入第 {}/{} 批向量。",
|
||||
processed_chunks.div_ceil(batch_size),
|
||||
total_chunks.div_ceil(batch_size)
|
||||
),
|
||||
total_chunks,
|
||||
processed_chunks,
|
||||
batch_size,
|
||||
);
|
||||
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
|
||||
}
|
||||
|
||||
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(AiIndexSummary {
|
||||
indexed_chunks: chunk_drafts.len(),
|
||||
|
||||
@@ -40,7 +40,12 @@ pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending";
|
||||
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
|
||||
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
|
||||
|
||||
const MAX_DELIVERY_ATTEMPTS: i32 = 5;
|
||||
const WEB_PUSH_TITLE_MAX_CHARS: usize = 72;
|
||||
const WEB_PUSH_BODY_MAX_CHARS: usize = 160;
|
||||
const WEB_PUSH_MAX_PAYLOAD_BYTES: usize = 2800;
|
||||
const WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD: i32 = 2;
|
||||
const WEB_PUSH_AUTO_PAUSE_NOTE: &str =
|
||||
"浏览器推送订阅连续投递失败,系统已自动暂停。请在浏览器里重新开启提醒。";
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DigestDispatchSummary {
|
||||
@@ -259,6 +264,97 @@ fn merge_browser_push_metadata(
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn merge_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
|
||||
let note = note.trim();
|
||||
let mut lines = existing
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !note.is_empty() && !lines.iter().any(|line| line == note) {
|
||||
lines.push(note.to_string());
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
|
||||
let note = note.trim();
|
||||
let lines = existing
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && *line != note)
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn web_push_error_looks_terminal(error_text: &str) -> bool {
|
||||
let normalized = error_text.trim().to_ascii_lowercase();
|
||||
|
||||
normalized.contains("endpoint_host=fcm.googleapis.com")
|
||||
&& normalized.contains("unspecified error")
|
||||
|| normalized.contains("410")
|
||||
|| normalized.contains("404")
|
||||
|| normalized.contains("gone")
|
||||
|| normalized.contains("not found")
|
||||
|| normalized.contains("expired")
|
||||
|| normalized.contains("unsubscribed")
|
||||
|| normalized.contains("invalid subscription")
|
||||
|| normalized.contains("push subscription")
|
||||
}
|
||||
|
||||
fn should_auto_pause_failed_web_push_subscription(
|
||||
failure_count_after_error: i32,
|
||||
error_text: &str,
|
||||
) -> bool {
|
||||
failure_count_after_error >= WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD
|
||||
|| web_push_error_looks_terminal(error_text)
|
||||
}
|
||||
|
||||
async fn maybe_pause_failed_web_push_subscription(
|
||||
ctx: &AppContext,
|
||||
subscription: Option<&subscriptions::Model>,
|
||||
error_text: &str,
|
||||
) -> Result<()> {
|
||||
let Some(subscription) = subscription else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if subscription.channel_type != CHANNEL_WEB_PUSH
|
||||
|| normalize_status(&subscription.status) != STATUS_ACTIVE
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let failure_count_after_error = subscription.failure_count.unwrap_or(0) + 1;
|
||||
if !should_auto_pause_failed_web_push_subscription(failure_count_after_error, error_text) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut active = subscription.clone().into_active_model();
|
||||
active.status = Set(STATUS_PAUSED.to_string());
|
||||
active.notes = Set(merge_subscription_note(
|
||||
subscription.notes.as_deref(),
|
||||
WEB_PUSH_AUTO_PAUSE_NOTE,
|
||||
));
|
||||
let _ = active.update(&ctx.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
|
||||
value
|
||||
.and_then(Value::as_object)
|
||||
@@ -321,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
|
||||
values
|
||||
}
|
||||
|
||||
fn delivery_retry_delay(attempts: i32) -> Duration {
|
||||
match attempts {
|
||||
0 | 1 => Duration::minutes(1),
|
||||
2 => Duration::minutes(5),
|
||||
3 => Duration::minutes(15),
|
||||
4 => Duration::minutes(60),
|
||||
_ => Duration::hours(6),
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
|
||||
match period.trim().to_ascii_lowercase().as_str() {
|
||||
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
|
||||
@@ -680,6 +766,12 @@ pub async fn create_public_web_push_subscription(
|
||||
active.status = Set(STATUS_ACTIVE.to_string());
|
||||
active.confirm_token = Set(None);
|
||||
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
|
||||
active.failure_count = Set(Some(0));
|
||||
active.last_delivery_status = Set(None);
|
||||
active.notes = Set(remove_subscription_note(
|
||||
existing.notes.as_deref(),
|
||||
WEB_PUSH_AUTO_PAUSE_NOTE,
|
||||
));
|
||||
active.metadata = Set(Some(merge_browser_push_metadata(
|
||||
existing.metadata.as_ref(),
|
||||
metadata,
|
||||
@@ -1066,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
|
||||
}
|
||||
|
||||
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
|
||||
let body = truncate_chars(&collapse_whitespace(&message.text), 220);
|
||||
|
||||
serde_json::json!({
|
||||
"title": message.subject,
|
||||
"body": body,
|
||||
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
|
||||
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
|
||||
"url": web_push_target_url(message),
|
||||
"tag": message
|
||||
let title = truncate_chars(
|
||||
&collapse_whitespace(&message.subject),
|
||||
WEB_PUSH_TITLE_MAX_CHARS,
|
||||
);
|
||||
let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
|
||||
let url = web_push_target_url(message);
|
||||
let event_type = message
|
||||
.payload
|
||||
.get("event_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("subscription"),
|
||||
.unwrap_or("subscription");
|
||||
|
||||
serde_json::json!({
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
|
||||
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
|
||||
"url": url.clone(),
|
||||
"tag": event_type,
|
||||
"data": {
|
||||
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null),
|
||||
"payload": message.payload,
|
||||
"url": url,
|
||||
"event_type": event_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_web_push_payload(message: &QueuedDeliveryPayload) -> Result<Vec<u8>> {
|
||||
let payload = build_web_push_payload(message);
|
||||
let encoded = serde_json::to_vec(&payload)?;
|
||||
|
||||
if encoded.len() > WEB_PUSH_MAX_PAYLOAD_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"web push payload too large: {} bytes exceeds safe limit {} bytes",
|
||||
encoded.len(),
|
||||
WEB_PUSH_MAX_PAYLOAD_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
async fn deliver_via_channel(
|
||||
ctx: &AppContext,
|
||||
channel_type: &str,
|
||||
@@ -1126,7 +1239,7 @@ async fn deliver_via_channel(
|
||||
CHANNEL_WEB_PUSH => {
|
||||
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
||||
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
|
||||
let payload = serde_json::to_vec(&build_web_push_payload(message))?;
|
||||
let payload = encode_web_push_payload(message)?;
|
||||
web_push_service::send_payload(
|
||||
&settings,
|
||||
&subscription_info,
|
||||
@@ -1275,24 +1388,25 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS)
|
||||
.then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339());
|
||||
let status = if next_retry_at.is_some() {
|
||||
DELIVERY_STATUS_RETRY_PENDING
|
||||
} else {
|
||||
DELIVERY_STATUS_EXHAUSTED
|
||||
};
|
||||
|
||||
let error_text = error.to_string();
|
||||
let mut active = delivery.into_active_model();
|
||||
active.status = Set(status.to_string());
|
||||
active.status = Set(DELIVERY_STATUS_EXHAUSTED.to_string());
|
||||
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
|
||||
active.response_text = Set(Some(error.to_string()));
|
||||
active.response_text = Set(Some(error_text.clone()));
|
||||
active.attempts_count = Set(attempts);
|
||||
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
|
||||
active.next_retry_at = Set(next_retry_at);
|
||||
active.next_retry_at = Set(None);
|
||||
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
|
||||
let _ = active.update(&ctx.db).await?;
|
||||
update_subscription_delivery_state(ctx, subscription_id, status, false).await?;
|
||||
update_subscription_delivery_state(
|
||||
ctx,
|
||||
subscription_id,
|
||||
DELIVERY_STATUS_EXHAUSTED,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
maybe_pause_failed_web_push_subscription(ctx, subscription.as_ref(), &error_text)
|
||||
.await?;
|
||||
Err(error)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use loco_rs::prelude::*;
|
||||
use reqwest::Url;
|
||||
use serde_json::Value;
|
||||
use web_push::{
|
||||
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
|
||||
@@ -46,17 +47,30 @@ pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
|
||||
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
|
||||
}
|
||||
|
||||
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
|
||||
vapid_subject(settings)
|
||||
.or_else(|| {
|
||||
site_url
|
||||
.map(str::trim)
|
||||
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
|
||||
.map(ToString::to_string)
|
||||
fn normalize_vapid_subject(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim();
|
||||
if trimmed.starts_with("mailto:") || trimmed.starts_with("https://") {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
|
||||
normalize_vapid_subject(vapid_subject(settings))
|
||||
.or_else(|| normalize_vapid_subject(site_url.map(ToString::to_string)))
|
||||
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
|
||||
}
|
||||
|
||||
fn subscription_endpoint_host(subscription_info: &SubscriptionInfo) -> String {
|
||||
Url::parse(&subscription_info.endpoint)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(ToString::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
|
||||
public_key(settings).is_some()
|
||||
}
|
||||
@@ -90,10 +104,12 @@ pub async fn send_payload(
|
||||
) -> Result<()> {
|
||||
let private_key = private_key(settings)
|
||||
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
|
||||
let subject = effective_vapid_subject(settings, site_url);
|
||||
let endpoint_host = subscription_endpoint_host(subscription_info);
|
||||
|
||||
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
|
||||
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
|
||||
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url));
|
||||
signature_builder.add_claim("sub", subject.clone());
|
||||
let signature = signature_builder
|
||||
.build()
|
||||
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
|
||||
@@ -111,10 +127,11 @@ pub async fn send_payload(
|
||||
.build()
|
||||
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
|
||||
|
||||
client
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?;
|
||||
client.send(message).await.map_err(|error| {
|
||||
Error::BadRequest(format!(
|
||||
"web push send failed: {error}; vapid subject={subject}; endpoint_host={endpoint_host}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
models::_entities::{notification_deliveries, worker_jobs},
|
||||
services::subscriptions,
|
||||
workers::{
|
||||
ai_reindex::{AiReindexWorker, AiReindexWorkerArgs},
|
||||
downloader::{DownloadWorker, DownloadWorkerArgs},
|
||||
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
|
||||
},
|
||||
@@ -27,6 +28,7 @@ pub const JOB_STATUS_CANCELLED: &str = "cancelled";
|
||||
|
||||
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
|
||||
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
|
||||
pub const WORKER_AI_REINDEX: &str = "worker.ai_reindex";
|
||||
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
|
||||
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
|
||||
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
|
||||
@@ -164,6 +166,7 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
|
||||
fn queue_name_for(worker_name: &str) -> Option<String> {
|
||||
match worker_name {
|
||||
WORKER_AI_REINDEX => Some("ai".to_string()),
|
||||
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
|
||||
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
|
||||
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
|
||||
@@ -174,6 +177,7 @@ fn queue_name_for(worker_name: &str) -> Option<String> {
|
||||
|
||||
fn label_for(worker_name: &str) -> String {
|
||||
match worker_name {
|
||||
WORKER_AI_REINDEX => "AI 索引重建".to_string(),
|
||||
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
|
||||
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
|
||||
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
|
||||
@@ -185,6 +189,7 @@ fn label_for(worker_name: &str) -> String {
|
||||
|
||||
fn description_for(worker_name: &str) -> String {
|
||||
match worker_name {
|
||||
WORKER_AI_REINDEX => "按当前站点内容重新生成 AI 检索索引,并分批写入向量数据。".to_string(),
|
||||
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
|
||||
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
|
||||
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
|
||||
@@ -196,6 +201,7 @@ fn description_for(worker_name: &str) -> String {
|
||||
|
||||
fn tags_for(worker_name: &str) -> Value {
|
||||
match worker_name {
|
||||
WORKER_AI_REINDEX => json!(["ai", "reindex"]),
|
||||
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
|
||||
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
|
||||
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
|
||||
@@ -249,6 +255,7 @@ fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
|
||||
|
||||
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
|
||||
[
|
||||
(WORKER_AI_REINDEX, JOB_KIND_WORKER, true, true),
|
||||
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
|
||||
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
|
||||
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
|
||||
@@ -313,6 +320,13 @@ async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_ai_reindex(args_ctx: AppContext, args: AiReindexWorkerArgs) {
|
||||
let worker = AiReindexWorker::build(&args_ctx);
|
||||
if let Err(error) = worker.perform(args).await {
|
||||
tracing::warn!("ai reindex worker execution failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_notification_delivery(
|
||||
args_ctx: AppContext,
|
||||
args: NotificationDeliveryWorkerArgs,
|
||||
@@ -340,6 +354,11 @@ async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) ->
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue_ai_reindex_worker(ctx: &AppContext, args: AiReindexWorkerArgs) -> Result<()> {
|
||||
tokio::spawn(dispatch_ai_reindex(ctx.clone(), args));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enqueue_notification_worker(
|
||||
ctx: &AppContext,
|
||||
args: NotificationDeliveryWorkerArgs,
|
||||
@@ -587,6 +606,28 @@ pub async fn mark_job_succeeded(ctx: &AppContext, id: i32, result: Option<Value>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Result<()> {
|
||||
let item = find_job(ctx, id).await?;
|
||||
let mut active = item.into_active_model();
|
||||
active.result = Set(Some(result));
|
||||
active.update(&ctx.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_job_if_requested(ctx: &AppContext, id: i32, reason: &str) -> Result<bool> {
|
||||
let item = find_job(ctx, id).await?;
|
||||
if item.status == JOB_STATUS_CANCELLED {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if item.cancel_requested {
|
||||
finish_job_cancelled(ctx, id, Some(reason.to_string())).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
|
||||
let item = find_job(ctx, id).await?;
|
||||
let mut active = item.into_active_model();
|
||||
@@ -681,6 +722,13 @@ pub async fn queue_notification_delivery_job(
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let mut delivery_active = delivery.clone().into_active_model();
|
||||
delivery_active.status = Set(subscriptions::DELIVERY_STATUS_QUEUED.to_string());
|
||||
delivery_active.response_text = Set(None);
|
||||
delivery_active.next_retry_at = Set(None);
|
||||
delivery_active.delivered_at = Set(None);
|
||||
delivery_active.attempts_count = Set(0);
|
||||
let delivery = delivery_active.update(&ctx.db).await?;
|
||||
|
||||
let base_args = NotificationDeliveryWorkerArgs {
|
||||
delivery_id,
|
||||
@@ -717,6 +765,46 @@ pub async fn queue_notification_delivery_job(
|
||||
get_job_record(ctx, job.id).await
|
||||
}
|
||||
|
||||
pub async fn queue_ai_reindex_job(
|
||||
ctx: &AppContext,
|
||||
requested_by: Option<String>,
|
||||
requested_source: Option<String>,
|
||||
parent_job_id: Option<i32>,
|
||||
trigger_mode: Option<String>,
|
||||
) -> Result<WorkerJobRecord> {
|
||||
let base_args = AiReindexWorkerArgs { job_id: None };
|
||||
let payload = serde_json::to_value(&base_args)?;
|
||||
|
||||
let job = create_job(
|
||||
ctx,
|
||||
CreateWorkerJobInput {
|
||||
parent_job_id,
|
||||
job_kind: JOB_KIND_WORKER.to_string(),
|
||||
worker_name: WORKER_AI_REINDEX.to_string(),
|
||||
display_name: Some("重建 AI 索引".to_string()),
|
||||
queue_name: queue_name_for(WORKER_AI_REINDEX),
|
||||
requested_by,
|
||||
requested_source,
|
||||
trigger_mode,
|
||||
payload: Some(payload),
|
||||
tags: Some(tags_for(WORKER_AI_REINDEX)),
|
||||
related_entity_type: Some("ai_index".to_string()),
|
||||
related_entity_id: Some("site".to_string()),
|
||||
max_attempts: 1,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
enqueue_ai_reindex_worker(
|
||||
ctx,
|
||||
AiReindexWorkerArgs {
|
||||
job_id: Some(job.id),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
get_job_record(ctx, job.id).await
|
||||
}
|
||||
|
||||
pub async fn spawn_retry_deliveries_task(
|
||||
ctx: &AppContext,
|
||||
limit: Option<u64>,
|
||||
@@ -810,6 +898,17 @@ pub async fn retry_job(
|
||||
let payload = item.payload.clone().unwrap_or(Value::Null);
|
||||
|
||||
match item.worker_name.as_str() {
|
||||
WORKER_AI_REINDEX => {
|
||||
let _ = serde_json::from_value::<AiReindexWorkerArgs>(payload)?;
|
||||
queue_ai_reindex_job(
|
||||
ctx,
|
||||
requested_by,
|
||||
requested_source,
|
||||
Some(item.id),
|
||||
Some("retry".to_string()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
WORKER_DOWNLOAD_MEDIA => {
|
||||
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
|
||||
queue_download_job(
|
||||
|
||||
77
backend/src/workers/ai_reindex.rs
Normal file
77
backend/src/workers/ai_reindex.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod ai_reindex;
|
||||
pub mod downloader;
|
||||
pub mod notification_delivery;
|
||||
|
||||
@@ -20,10 +20,6 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
|
||||
Self { ctx: ctx.clone() }
|
||||
}
|
||||
|
||||
fn tags() -> Vec<String> {
|
||||
vec!["notifications".to_string()]
|
||||
}
|
||||
|
||||
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
|
||||
if let Some(job_id) = args.job_id {
|
||||
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
|
||||
|
||||
@@ -3,6 +3,17 @@ BACKEND_PORT=5150
|
||||
FRONTEND_PORT=4321
|
||||
ADMIN_PORT=4322
|
||||
|
||||
# 建议在小内存主机上给每个服务设置明确上限,避免 backend 在 AI 重建索引时
|
||||
# 把整台主机拖进 swap 抖动。默认值与 compose.package.yml 保持一致。
|
||||
BACKEND_MEMORY_LIMIT=768m
|
||||
BACKEND_MEMORY_SWAP_LIMIT=768m
|
||||
BACKEND_WORKER_MEMORY_LIMIT=1g
|
||||
BACKEND_WORKER_MEMORY_SWAP_LIMIT=1g
|
||||
FRONTEND_MEMORY_LIMIT=256m
|
||||
FRONTEND_MEMORY_SWAP_LIMIT=256m
|
||||
ADMIN_MEMORY_LIMIT=128m
|
||||
ADMIN_MEMORY_SWAP_LIMIT=128m
|
||||
|
||||
# frontend SSR 服务端访问 backend 用这个内部地址(compose 默认可直接使用)
|
||||
INTERNAL_API_BASE_URL=http://backend:5150/api
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ backend-worker
|
||||
|
||||
如果只启动 `backend` 而没有 `backend-worker`,通知会入队但没人消费。
|
||||
|
||||
补充说明:
|
||||
|
||||
- `backend-worker` 目前主要消费 Redis 队列里的通知相关任务。
|
||||
- AI 索引重建会直接在 `backend` 进程本地启动,这样创建任务后会立即进入执行,不再依赖独立 worker 消费。
|
||||
|
||||
## 2.1 推荐的后台认证链路
|
||||
|
||||
当前最推荐:
|
||||
|
||||
@@ -43,6 +43,10 @@ python deploy/scripts/render_compose_env.py \
|
||||
|
||||
建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量:
|
||||
|
||||
- `BACKEND_MEMORY_LIMIT / BACKEND_MEMORY_SWAP_LIMIT`:backend 容器内存 / swap 上限;对小内存主机建议显式设置
|
||||
- `BACKEND_WORKER_MEMORY_LIMIT / BACKEND_WORKER_MEMORY_SWAP_LIMIT`:worker 容器内存 / swap 上限
|
||||
- `FRONTEND_MEMORY_LIMIT / FRONTEND_MEMORY_SWAP_LIMIT`:frontend 容器内存 / swap 上限
|
||||
- `ADMIN_MEMORY_LIMIT / ADMIN_MEMORY_SWAP_LIMIT`:admin 容器内存 / swap 上限
|
||||
- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api`
|
||||
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`”
|
||||
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
|
||||
@@ -62,6 +66,14 @@ python deploy/scripts/render_compose_env.py \
|
||||
|
||||
```yaml
|
||||
compose_env:
|
||||
BACKEND_MEMORY_LIMIT: 768m
|
||||
BACKEND_MEMORY_SWAP_LIMIT: 768m
|
||||
BACKEND_WORKER_MEMORY_LIMIT: 1g
|
||||
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
|
||||
FRONTEND_MEMORY_LIMIT: 256m
|
||||
FRONTEND_MEMORY_SWAP_LIMIT: 256m
|
||||
ADMIN_MEMORY_LIMIT: 128m
|
||||
ADMIN_MEMORY_SWAP_LIMIT: 128m
|
||||
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
||||
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
|
||||
@@ -136,6 +148,7 @@ A: 当前站点对外内容页优先 SEO 与首屏可见性,保留 SSR 更稳
|
||||
A: 推荐前置 Caddy/Nginx 统一暴露 `80/443`,`frontend:4321` / `backend:5150` / `admin:80` 仅走内网。
|
||||
当前 `compose.package.yml` 属于直连端口版,便于快速部署与联调。
|
||||
另外因为通知已经走异步队列,生产务必同时启动 `backend-worker`。
|
||||
AI 索引重建当前直接在 `backend` 进程本地启动,不依赖 `backend-worker` 消费 Redis 队列。
|
||||
|
||||
### Q5: 为什么 compose 里没看到 `ADMIN_VITE_FRONTEND_BASE_URL`?
|
||||
A:
|
||||
@@ -178,6 +191,7 @@ A:
|
||||
A:
|
||||
- `backend` 镜像启动时会先执行 `db migrate`
|
||||
- `backend` 提供 `/healthz`
|
||||
- `backend-worker` 不提供 HTTP `/healthz`;compose 会覆盖镜像默认 healthcheck,改为检查主进程是否仍以 `--worker` 模式运行
|
||||
- `frontend` 提供 `/healthz`
|
||||
- `admin` 继续由 Nginx 提供 `/healthz`
|
||||
- compose 现在使用 `depends_on.condition: service_healthy`
|
||||
|
||||
@@ -3,6 +3,10 @@ services:
|
||||
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
# 对 tohka 这类小内存主机,建议给服务设置明确上限,
|
||||
# 避免 AI 重建索引时把整机拖进 swap 抖动 / OOM。
|
||||
mem_limit: ${BACKEND_MEMORY_LIMIT:-768m}
|
||||
memswap_limit: ${BACKEND_MEMORY_SWAP_LIMIT:-768m}
|
||||
environment:
|
||||
PORT: 5150
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150}
|
||||
@@ -30,6 +34,8 @@ services:
|
||||
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
mem_limit: ${BACKEND_WORKER_MEMORY_LIMIT:-1g}
|
||||
memswap_limit: ${BACKEND_WORKER_MEMORY_SWAP_LIMIT:-1g}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -48,11 +54,22 @@ services:
|
||||
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
TERMI_SKIP_MIGRATIONS: 'true'
|
||||
# backend 镜像默认 healthcheck 会探测 HTTP /healthz,
|
||||
# 但 worker 模式不监听 5150,所以这里改成“主进程仍然是 --worker”检查。
|
||||
healthcheck:
|
||||
test:
|
||||
['CMD-SHELL', "test -r /proc/1/cmdline && tr '\\000' ' ' </proc/1/cmdline | grep -q -- '--worker'"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
mem_limit: ${FRONTEND_MEMORY_LIMIT:-256m}
|
||||
memswap_limit: ${FRONTEND_MEMORY_SWAP_LIMIT:-256m}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -78,6 +95,8 @@ services:
|
||||
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
mem_limit: ${ADMIN_MEMORY_LIMIT:-128m}
|
||||
memswap_limit: ${ADMIN_MEMORY_SWAP_LIMIT:-128m}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -25,6 +25,14 @@ compose_env:
|
||||
BACKEND_PORT: 5150
|
||||
FRONTEND_PORT: 4321
|
||||
ADMIN_PORT: 4322
|
||||
BACKEND_MEMORY_LIMIT: 768m
|
||||
BACKEND_MEMORY_SWAP_LIMIT: 768m
|
||||
BACKEND_WORKER_MEMORY_LIMIT: 1g
|
||||
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
|
||||
FRONTEND_MEMORY_LIMIT: 256m
|
||||
FRONTEND_MEMORY_SWAP_LIMIT: 256m
|
||||
ADMIN_MEMORY_LIMIT: 128m
|
||||
ADMIN_MEMORY_SWAP_LIMIT: 128m
|
||||
|
||||
APP_BASE_URL: https://admin.blog.init.cool
|
||||
INTERNAL_API_BASE_URL: http://backend:5150/api
|
||||
|
||||
4
dev.ps1
4
dev.ps1
@@ -105,8 +105,8 @@ function Start-Backend {
|
||||
-Run {
|
||||
$env:DATABASE_URL = $DatabaseUrl
|
||||
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
|
||||
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green
|
||||
cargo loco start 2>&1
|
||||
Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
|
||||
cargo loco start --server-and-worker 2>&1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
frontend/public/favicon-default.ico
Normal file
BIN
frontend/public/favicon-default.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -1,9 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="18" y1="10" x2="110" y2="118" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#132334" />
|
||||
<stop offset="1" stop-color="#081019" />
|
||||
</linearGradient>
|
||||
<linearGradient id="toolbar" x1="20" y1="18" x2="108" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#24384B" />
|
||||
<stop offset="1" stop-color="#162633" />
|
||||
</linearGradient>
|
||||
<linearGradient id="prompt" x1="40" y1="44" x2="92" y2="92" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9AF7E3" />
|
||||
<stop offset="1" stop-color="#48CFFF" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="18" y="28" width="92" height="78" filterUnits="userSpaceOnUse">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect x="10" y="10" width="108" height="108" rx="28" fill="url(#bg)" />
|
||||
<rect x="11.5" y="11.5" width="105" height="105" rx="26.5" stroke="#5FE1FF" stroke-opacity=".18" stroke-width="3" />
|
||||
<path d="M10 38c0-15.464 12.536-28 28-28h52c15.464 0 28 12.536 28 28v2H10v-2Z" fill="url(#toolbar)" />
|
||||
|
||||
<circle cx="28" cy="26" r="5.5" fill="#FF6B6B" />
|
||||
<circle cx="44" cy="26" r="5.5" fill="#FFD166" />
|
||||
<circle cx="60" cy="26" r="5.5" fill="#57D68D" />
|
||||
|
||||
<g filter="url(#glow)">
|
||||
<path
|
||||
d="M37 50 65 64 37 78 31 71.5 48.5 64 31 56.5 37 50Z"
|
||||
fill="url(#prompt)"
|
||||
/>
|
||||
<rect x="69" y="74" width="26" height="8.5" rx="4.25" fill="url(#prompt)" />
|
||||
</g>
|
||||
|
||||
<path
|
||||
d="M86 18h17c4.418 0 8 3.582 8 8v17l-25-25Z"
|
||||
fill="#1DE9B6"
|
||||
fill-opacity=".16"
|
||||
/>
|
||||
<circle cx="99" cy="29" r="8" fill="#0B1621" stroke="#1DE9B6" stroke-width="2.5" />
|
||||
<path d="M96 29h6" stroke="#1DE9B6" stroke-linecap="round" stroke-width="2.75" />
|
||||
<path d="M99 26v6" stroke="#1DE9B6" stroke-linecap="round" stroke-width="2.75" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 2.0 KiB |
@@ -1,4 +1,4 @@
|
||||
self.addEventListener('push', (event) => {
|
||||
self.addEventListener("push", (event) => {
|
||||
const payload = (() => {
|
||||
if (!event.data) {
|
||||
return {};
|
||||
@@ -13,30 +13,50 @@ self.addEventListener('push', (event) => {
|
||||
}
|
||||
})();
|
||||
|
||||
const title = payload.title || '订阅更新';
|
||||
const url = payload.url || '/';
|
||||
const notifyClients = self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clients) =>
|
||||
Promise.all(
|
||||
clients.map((client) =>
|
||||
client.postMessage({
|
||||
type: "termi:web-push-received",
|
||||
payload,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const title = payload.title || "订阅更新";
|
||||
const url = payload.url || "/";
|
||||
const options = {
|
||||
body: payload.body || '',
|
||||
icon: payload.icon || '/favicon.svg',
|
||||
badge: payload.badge || '/favicon.ico',
|
||||
tag: payload.tag || 'termi-subscription',
|
||||
body: payload.body || "",
|
||||
icon: payload.icon || "/favicon.svg",
|
||||
badge: payload.badge || "/favicon.ico",
|
||||
tag: payload.tag || "termi-subscription",
|
||||
data: {
|
||||
url,
|
||||
...(payload.data || {}),
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
self.registration.showNotification(title, options),
|
||||
notifyClients,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const targetUrl = event.notification?.data?.url || '/';
|
||||
const targetUrl = event.notification?.data?.url || "/";
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ('focus' in client && client.url === targetUrl) {
|
||||
if ("focus" in client && client.url === targetUrl) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ const {
|
||||
const { locale, t, buildLocaleUrl } = getI18n(Astro);
|
||||
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
|
||||
const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
|
||||
const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter(
|
||||
const configuredMusicPlaylist = Astro.props.siteSettings?.musicPlaylist ?? [];
|
||||
const musicPlaylist = (musicEnabled ? configuredMusicPlaylist : []).filter(
|
||||
(item) => item?.title?.trim() && item?.url?.trim()
|
||||
);
|
||||
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
|
||||
@@ -34,6 +35,14 @@ const navItems = [
|
||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||
];
|
||||
const mobileDockItems = [
|
||||
{ icon: 'fa-house', text: t('common.home'), href: '/' },
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
...(aiEnabled
|
||||
? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }]
|
||||
: [{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }]),
|
||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||
];
|
||||
const localeLinks = SUPPORTED_LOCALES.map((item) => ({
|
||||
locale: item,
|
||||
href: buildLocaleUrl(item),
|
||||
@@ -148,7 +157,7 @@ const currentNavLabel =
|
||||
{aiEnabled && (
|
||||
<a
|
||||
href="/ask"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_28%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.10)] transition hover:border-[color:color-mix(in_oklab,var(--primary)_40%,var(--border-color))] hover:text-[var(--title-color)]"
|
||||
>
|
||||
<i class="fas fa-robot text-sm"></i>
|
||||
<span class="hidden xl:inline">{t('nav.ask')}</span>
|
||||
@@ -356,6 +365,40 @@ const currentNavLabel =
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.8rem+env(safe-area-inset-bottom))] lg:hidden">
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="grid grid-cols-5 gap-1 rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_96%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent))] p-1.5 shadow-[0_18px_36px_rgba(15,23,42,0.22)] backdrop-blur-xl">
|
||||
{mobileDockItems.map((item) => {
|
||||
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'flex min-w-0 flex-col items-center gap-1 rounded-[18px] px-2 py-2 text-[11px] font-medium transition',
|
||||
isActive
|
||||
? 'border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] text-[var(--primary)]'
|
||||
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]',
|
||||
]}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<i class={`fas ${item.icon} text-[13px]`}></i>
|
||||
<span class="truncate">{item.text}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-col items-center gap-1 rounded-[18px] border border-transparent px-2 py-2 text-[11px] font-medium text-[var(--text-secondary)] transition hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]"
|
||||
data-mobile-dock-menu
|
||||
aria-label={t('header.toggleMenu')}
|
||||
>
|
||||
<i class="fas fa-bars text-[13px]"></i>
|
||||
<span class="truncate">{t('header.navigation')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
@@ -364,12 +407,16 @@ const currentNavLabel =
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileSearchInput = document.getElementById('mobile-search-input');
|
||||
const mobileSearchBtn = document.getElementById('mobile-search-btn');
|
||||
const mobileDockMenuBtn = document.querySelector('[data-mobile-dock-menu]');
|
||||
|
||||
mobileMenuBtn?.addEventListener('click', () => {
|
||||
function toggleMobileMenu() {
|
||||
const nextExpanded = mobileMenu?.classList.contains('hidden');
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
mobileMenuBtn.setAttribute('aria-expanded', String(nextExpanded));
|
||||
});
|
||||
mobileMenuBtn?.setAttribute('aria-expanded', String(nextExpanded));
|
||||
}
|
||||
|
||||
mobileMenuBtn?.addEventListener('click', toggleMobileMenu);
|
||||
mobileDockMenuBtn?.addEventListener('click', toggleMobileMenu);
|
||||
|
||||
document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
|
||||
@@ -10,10 +10,10 @@ const { stats } = Astro.props;
|
||||
|
||||
<ul class="grid gap-3">
|
||||
{stats.map((stat, index) => (
|
||||
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
|
||||
<li class="terminal-panel-muted terminal-panel-accent terminal-interactive-card rounded-2xl px-4 py-4" style="--accent-rgb: var(--primary-rgb); --accent-color: var(--primary);">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-transparent bg-[var(--primary)] text-[var(--terminal-bg)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.22)]">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_16%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_18%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_10%,var(--header-bg)))] text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--text-rgb),0.06)]">
|
||||
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
@@ -21,7 +21,7 @@ const { stats } = Astro.props;
|
||||
<div class="mt-1 text-lg font-semibold text-[var(--title-color)]">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.3),transparent)]"></span>
|
||||
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.22),transparent)]"></span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -155,13 +155,31 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
<i class="fas fa-envelope-open-text"></i>
|
||||
</span>
|
||||
<span class="subscription-popup-channel-toggle-copy">
|
||||
<span class="subscription-popup-channel-toggle-meta" aria-hidden="true">
|
||||
<span class="subscription-popup-channel-toggle-tag">可选</span>
|
||||
<span class="subscription-popup-channel-toggle-tag subscription-popup-channel-toggle-tag--email">
|
||||
Email
|
||||
</span>
|
||||
</span>
|
||||
<span class="subscription-popup-channel-toggle-title" data-subscription-popup-channel-toggle-label>
|
||||
开启邮件订阅
|
||||
添加邮件订阅
|
||||
</span>
|
||||
<span class="subscription-popup-channel-toggle-description">
|
||||
需要时再补一个邮箱备份
|
||||
<span
|
||||
class="subscription-popup-channel-toggle-description"
|
||||
data-subscription-popup-channel-toggle-description
|
||||
>
|
||||
填写邮箱后,更新也会发到你的收件箱
|
||||
</span>
|
||||
</span>
|
||||
<span class="subscription-popup-channel-toggle-affordance" aria-hidden="true">
|
||||
<span
|
||||
class="subscription-popup-channel-toggle-affordance-text"
|
||||
data-subscription-popup-channel-toggle-affordance
|
||||
>
|
||||
去填写
|
||||
</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +360,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||
import {
|
||||
ensureBrowserPushSubscription,
|
||||
getBrowserPushSubscription,
|
||||
getBrowserPushSubscriptionState,
|
||||
supportsBrowserPush,
|
||||
} from '../lib/utils/web-push';
|
||||
|
||||
@@ -376,6 +394,12 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
const emailToggleLabel = emailToggleButton?.querySelector(
|
||||
'[data-subscription-popup-channel-toggle-label]',
|
||||
) as HTMLElement | null;
|
||||
const emailToggleDescription = emailToggleButton?.querySelector(
|
||||
'[data-subscription-popup-channel-toggle-description]',
|
||||
) as HTMLElement | null;
|
||||
const emailToggleAffordance = emailToggleButton?.querySelector(
|
||||
'[data-subscription-popup-channel-toggle-affordance]',
|
||||
) as HTMLElement | null;
|
||||
const browserCard = root.querySelector(
|
||||
'[data-subscription-popup-channel-card="browser"]',
|
||||
) as HTMLElement | null;
|
||||
@@ -481,6 +505,14 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
}
|
||||
};
|
||||
|
||||
const forgetSubmitted = () => {
|
||||
try {
|
||||
window.localStorage.removeItem(SUBSCRIBED_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
};
|
||||
|
||||
const resetStatus = () => {
|
||||
delete status.dataset.state;
|
||||
status.textContent = defaultStatus;
|
||||
@@ -572,7 +604,17 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
emailInput.required = emailSelected;
|
||||
|
||||
if (emailToggleLabel instanceof HTMLElement) {
|
||||
emailToggleLabel.textContent = emailSelected ? '收起邮件订阅' : '开启邮件订阅';
|
||||
emailToggleLabel.textContent = emailSelected ? '收起邮件订阅' : '添加邮件订阅';
|
||||
}
|
||||
|
||||
if (emailToggleDescription instanceof HTMLElement) {
|
||||
emailToggleDescription.textContent = emailSelected
|
||||
? '邮箱表单已经展开,填好后提交即可作为额外备份'
|
||||
: '填写邮箱后,更新也会发到你的收件箱';
|
||||
}
|
||||
|
||||
if (emailToggleAffordance instanceof HTMLElement) {
|
||||
emailToggleAffordance.textContent = emailSelected ? '收起' : '去填写';
|
||||
}
|
||||
|
||||
setToggleState(emailToggleButton, emailSelected, !browserRequired);
|
||||
@@ -777,7 +819,19 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await getBrowserPushSubscription();
|
||||
const { subscription, stale } = await getBrowserPushSubscriptionState(
|
||||
browserPushPublicKey,
|
||||
);
|
||||
|
||||
if (stale) {
|
||||
forgetSubmitted();
|
||||
setBrowserAvailability({
|
||||
selectable: true,
|
||||
note: '检测到提醒配置已更新,需要重新开启一次提醒。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
rememberSubmitted();
|
||||
setBrowserAvailability({
|
||||
@@ -1021,7 +1075,8 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
gap: 1rem;
|
||||
margin-top: var(--subscription-popup-offset, calc(env(safe-area-inset-top, 0px) + 5.25rem));
|
||||
padding: 1.1rem;
|
||||
border-radius: 1.7rem;
|
||||
padding-top: 3.5rem;
|
||||
border-radius: 1.55rem;
|
||||
opacity: 0;
|
||||
transform: translateY(-1rem) scale(0.985);
|
||||
transition:
|
||||
@@ -1031,16 +1086,20 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(16px) saturate(135%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.15), transparent 26%),
|
||||
radial-gradient(circle at bottom right, rgba(var(--secondary-rgb, var(--primary-rgb)), 0.1), transparent 28%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.09),
|
||||
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.04) 42%,
|
||||
transparent 72%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--terminal-bg) 99%, white),
|
||||
color-mix(in oklab, var(--header-bg) 93%, white)
|
||||
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
|
||||
color-mix(in oklab, var(--header-bg) 92%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
0 28px 70px rgba(var(--text-rgb), 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.subscription-popup-panel::before {
|
||||
@@ -1066,20 +1125,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
position: absolute;
|
||||
top: 0.8rem;
|
||||
right: 0.8rem;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--terminal-bg) 94%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--header-bg) 90%, var(--terminal-bg));
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-shadow:
|
||||
0 10px 22px rgba(var(--text-rgb), 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.subscription-popup-close:hover {
|
||||
@@ -1097,23 +1161,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
.subscription-popup-main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.subscription-popup-copy-surface,
|
||||
.subscription-popup-channel-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: 1.2rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--terminal-bg) 99%, white),
|
||||
color-mix(in oklab, var(--header-bg) 94%, white)
|
||||
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
|
||||
color-mix(in oklab, var(--header-bg) 91%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 30px rgba(var(--text-rgb), 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.subscription-popup-copy-surface {
|
||||
@@ -1167,7 +1233,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
min-height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--terminal-bg) 98%, white);
|
||||
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
@@ -1191,13 +1257,50 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
|
||||
color: var(--primary);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-copy {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--border-color) 88%, transparent);
|
||||
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-tag--email {
|
||||
color: color-mix(in oklab, var(--secondary, var(--primary)) 62%, var(--title-color));
|
||||
border-color: color-mix(
|
||||
in oklab,
|
||||
var(--secondary, var(--primary)) 22%,
|
||||
var(--border-color)
|
||||
);
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--secondary, var(--primary)) 11%,
|
||||
var(--terminal-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-title {
|
||||
@@ -1215,6 +1318,27 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-affordance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
color: color-mix(in oklab, var(--secondary, var(--primary)) 58%, var(--title-color));
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle-affordance i {
|
||||
font-size: 0.72rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
|
||||
@@ -1222,6 +1346,10 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
box-shadow: 0 14px 28px rgba(var(--text-rgb), 0.08);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle:hover .subscription-popup-channel-toggle-affordance {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle.is-active {
|
||||
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
|
||||
background:
|
||||
@@ -1240,12 +1368,16 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
background: color-mix(in oklab, var(--primary) 16%, var(--terminal-bg));
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle.is-active .subscription-popup-channel-toggle-affordance i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-toggle--primary {
|
||||
border-color: color-mix(in oklab, var(--primary) 52%, white);
|
||||
border-color: color-mix(in oklab, var(--primary) 48%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--primary) 88%, white),
|
||||
color-mix(in oklab, var(--primary) 82%, white),
|
||||
color-mix(in oklab, var(--primary) 72%, var(--header-bg))
|
||||
);
|
||||
color: white;
|
||||
@@ -1285,7 +1417,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--secondary, var(--primary)) 26%, white),
|
||||
color-mix(in oklab, var(--secondary, var(--primary)) 22%, var(--terminal-bg)),
|
||||
color-mix(in oklab, var(--secondary, var(--primary)) 14%, var(--header-bg))
|
||||
);
|
||||
box-shadow:
|
||||
@@ -1490,11 +1622,11 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--header-bg) 96%, white),
|
||||
color-mix(in oklab, var(--terminal-bg) 98%, white)
|
||||
color-mix(in oklab, var(--header-bg) 94%, transparent),
|
||||
color-mix(in oklab, var(--terminal-bg) 97%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 14px 32px rgba(var(--text-rgb), 0.05);
|
||||
}
|
||||
|
||||
@@ -1589,7 +1721,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
|
||||
color: var(--primary);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.subscription-popup-channel-icon--mail {
|
||||
@@ -1615,7 +1747,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 1.05rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--header-bg) 65%, white);
|
||||
background: color-mix(in oklab, var(--header-bg) 88%, var(--terminal-bg));
|
||||
padding: 0.9rem 0.95rem;
|
||||
}
|
||||
|
||||
@@ -1736,7 +1868,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
|
||||
.subscription-popup-panel {
|
||||
gap: 0.9rem;
|
||||
padding: 0.95rem 0.9rem 0.95rem;
|
||||
padding: 3.1rem 0.9rem 0.95rem;
|
||||
}
|
||||
|
||||
.subscription-popup-copy-mark {
|
||||
@@ -1756,7 +1888,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
||||
|
||||
.subscription-popup-copy-surface,
|
||||
.subscription-popup-channel-card {
|
||||
border-radius: 1.2rem;
|
||||
border-radius: 1.05rem;
|
||||
}
|
||||
|
||||
.subscription-popup-copy-surface {
|
||||
|
||||
@@ -16,7 +16,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
|
||||
<div
|
||||
id="toc-panel"
|
||||
class="rounded-[24px] border border-[var(--border-color)]/72 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,252,255,0.92))] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.055)] backdrop-blur"
|
||||
class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.045),transparent_56%)] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.08)] backdrop-blur"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<span class="terminal-kicker w-fit">
|
||||
@@ -130,8 +130,8 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
height: 0.52rem;
|
||||
margin-top: 0.42rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--border-color) 82%, white 18%);
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, var(--card-bg, white) 92%, transparent);
|
||||
background: color-mix(in oklab, var(--border-color) 82%, var(--terminal-bg));
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, var(--terminal-bg) 92%, transparent);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease,
|
||||
@@ -145,7 +145,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
#toc-nav .toc-link.is-active .toc-link-dot {
|
||||
background: var(--primary);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, white 90%);
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
|
||||
}
|
||||
|
||||
#toc-nav .toc-link-sub .toc-link-dot {
|
||||
@@ -189,6 +189,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
const tocPanel = document.getElementById('toc-panel');
|
||||
const container = document.getElementById('toc-container');
|
||||
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
|
||||
const header = document.querySelector('header');
|
||||
|
||||
if (!tocNav || headings.length === 0) {
|
||||
if (tocPanel) tocPanel.style.display = 'none';
|
||||
@@ -196,6 +197,47 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
return;
|
||||
}
|
||||
|
||||
const getScrollOffset = () => {
|
||||
const headerRect = header instanceof HTMLElement ? header.getBoundingClientRect() : null;
|
||||
const headerHeight = headerRect ? Math.max(headerRect.height, headerRect.bottom) : 0;
|
||||
return Math.round(headerHeight + 20);
|
||||
};
|
||||
|
||||
const updateHeadingOffset = () => {
|
||||
const offset = `${getScrollOffset()}px`;
|
||||
headings.forEach((heading) => {
|
||||
if (heading instanceof HTMLElement) {
|
||||
heading.style.scrollMarginTop = offset;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToHeading = (heading) => {
|
||||
const offset = getScrollOffset();
|
||||
const targetTop = window.scrollY + heading.getBoundingClientRect().top - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: Math.max(targetTop, 0),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const setActiveLink = (headingId) => {
|
||||
const links = tocNav.querySelectorAll('a');
|
||||
|
||||
links.forEach((link) => {
|
||||
const isActive = link.getAttribute('href') === `#${headingId}`;
|
||||
link.classList.toggle('is-active', isActive);
|
||||
|
||||
if (isActive) {
|
||||
link.setAttribute('aria-current', 'true');
|
||||
link.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
} else {
|
||||
link.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tocNav.innerHTML = '';
|
||||
headings.forEach((heading, index) => {
|
||||
if (!heading.id) {
|
||||
@@ -212,32 +254,103 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setActiveLink(heading.id);
|
||||
scrollToHeading(heading);
|
||||
window.history.replaceState(null, '', `#${heading.id}`);
|
||||
});
|
||||
|
||||
tocNav.appendChild(link);
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const links = tocNav.querySelectorAll('a');
|
||||
links.forEach(link => {
|
||||
link.classList.remove('is-active');
|
||||
link.removeAttribute('aria-current');
|
||||
if (link.getAttribute('href') === `#${entry.target.id}`) {
|
||||
link.classList.add('is-active');
|
||||
link.setAttribute('aria-current', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '-20% 0px -75% 0px' }
|
||||
);
|
||||
updateHeadingOffset();
|
||||
|
||||
headings.forEach(heading => observer.observe(heading));
|
||||
let ticking = false;
|
||||
let scrollEndTimer = 0;
|
||||
let suppressScrollTracking = false;
|
||||
|
||||
const syncActiveHeading = () => {
|
||||
if (suppressScrollTracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = getScrollOffset();
|
||||
const threshold = offset + 24;
|
||||
const headingList = Array.from(headings).filter((heading) => heading instanceof HTMLElement);
|
||||
|
||||
let activeHeading = headingList[0] || null;
|
||||
|
||||
headingList.forEach((heading) => {
|
||||
if (heading.getBoundingClientRect().top <= threshold) {
|
||||
activeHeading = heading;
|
||||
}
|
||||
});
|
||||
|
||||
if (!activeHeading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom =
|
||||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
|
||||
|
||||
if (nearBottom) {
|
||||
activeHeading = headingList[headingList.length - 1] || activeHeading;
|
||||
}
|
||||
|
||||
setActiveLink(activeHeading.id);
|
||||
};
|
||||
|
||||
const requestActiveHeadingSync = () => {
|
||||
if (ticking) {
|
||||
return;
|
||||
}
|
||||
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
ticking = false;
|
||||
syncActiveHeading();
|
||||
});
|
||||
};
|
||||
|
||||
const releaseScrollTrackingSoon = () => {
|
||||
window.clearTimeout(scrollEndTimer);
|
||||
scrollEndTimer = window.setTimeout(() => {
|
||||
suppressScrollTracking = false;
|
||||
syncActiveHeading();
|
||||
}, 220);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
releaseScrollTrackingSoon();
|
||||
requestActiveHeadingSync();
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
updateHeadingOffset();
|
||||
requestActiveHeadingSync();
|
||||
}, { passive: true });
|
||||
|
||||
if (window.location.hash) {
|
||||
const target = document.getElementById(window.location.hash.slice(1));
|
||||
if (target instanceof HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
suppressScrollTracking = true;
|
||||
setActiveLink(target.id);
|
||||
scrollToHeading(target);
|
||||
releaseScrollTrackingSoon();
|
||||
});
|
||||
}
|
||||
} else if (headings[0] instanceof HTMLElement) {
|
||||
setActiveLink(headings[0].id);
|
||||
}
|
||||
|
||||
tocNav.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
suppressScrollTracking = true;
|
||||
releaseScrollTrackingSoon();
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(syncActiveHeading);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -10,9 +10,9 @@ const { items } = Astro.props;
|
||||
|
||||
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<li class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group overflow-hidden rounded-2xl" style="--accent-rgb: var(--primary-rgb); --accent-color: var(--primary);">
|
||||
<div class="flex items-start gap-3 px-4 py-4">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_14%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_16%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_9%,var(--header-bg)))] text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--text-rgb),0.06)]">
|
||||
<i class="fas fa-code text-xs"></i>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
|
||||
@@ -31,7 +31,7 @@ const {
|
||||
title = isEnglish ? 'Quick share' : '一键分享',
|
||||
description = isEnglish
|
||||
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
|
||||
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
|
||||
: '复制链接、带走二维码,轻轻一发,不剧透太多。',
|
||||
stats = [],
|
||||
wechatShareQrEnabled = false,
|
||||
variant = 'default',
|
||||
@@ -70,30 +70,30 @@ const copy = isEnglish
|
||||
toastInfoTitle: 'Share ready',
|
||||
}
|
||||
: {
|
||||
summaryTitle: '页面简介',
|
||||
canonical: '固定链接',
|
||||
copySummary: '复制简介',
|
||||
copySummarySuccess: '页面简介已复制',
|
||||
summaryTitle: '小纸条',
|
||||
canonical: '传送门',
|
||||
copySummary: '复制小纸条',
|
||||
copySummarySuccess: '小纸条已复制',
|
||||
copySummaryFailed: '复制失败',
|
||||
copyLink: '复制固定链接',
|
||||
copyLinkSuccess: '固定链接已复制',
|
||||
copyLinkFailed: '固定链接复制失败',
|
||||
shareSummary: '直接分享',
|
||||
shareSuccess: '已打开系统分享',
|
||||
shareFallback: '分享内容已复制',
|
||||
copyLink: '复制传送门',
|
||||
copyLinkSuccess: '传送门已复制',
|
||||
copyLinkFailed: '传送门复制失败',
|
||||
shareSummary: '一键甩出',
|
||||
shareSuccess: '系统分享已就位',
|
||||
shareFallback: '分享话术已复制',
|
||||
shareFailed: '分享失败',
|
||||
shareToX: '分享到 X',
|
||||
shareToTelegram: '分享到 Telegram',
|
||||
shareToWeChat: '微信扫一扫',
|
||||
qrModalTitle: '微信扫一扫',
|
||||
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。',
|
||||
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
|
||||
qrModalDescription: '扫一下,手机上继续逛。',
|
||||
qrModalHint: '真要转发,丢链接通常更省事。',
|
||||
downloadQr: '下载二维码',
|
||||
downloadQrStarted: '二维码开始下载',
|
||||
qrOpened: '微信二维码已打开',
|
||||
toastSuccessTitle: '操作完成',
|
||||
toastErrorTitle: '操作失败',
|
||||
toastInfoTitle: '已准备好',
|
||||
toastSuccessTitle: '搞定',
|
||||
toastErrorTitle: '这次没接住',
|
||||
toastInfoTitle: '可以发了',
|
||||
};
|
||||
|
||||
const safeSummary = summary.trim() || shareTitle;
|
||||
@@ -140,7 +140,9 @@ if (wechatShareQrEnabled) {
|
||||
|
||||
<section
|
||||
class:list={[
|
||||
'border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.1),rgba(var(--secondary-rgb),0.04)_46%,rgba(var(--bg-rgb),0.92))]',
|
||||
'border shadow-[0_16px_40px_rgba(15,23,42,0.08)]',
|
||||
'border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))]',
|
||||
'bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)]',
|
||||
isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6',
|
||||
]}
|
||||
data-share-panel-id={panelId}
|
||||
@@ -148,7 +150,7 @@ if (wechatShareQrEnabled) {
|
||||
<div class:list={['flex flex-col gap-4', !isCompact && 'lg:flex-row lg:items-start lg:justify-between']}>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_15%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]">
|
||||
<i class="fas fa-satellite-dish text-[10px]"></i>
|
||||
{visibleBadge}
|
||||
</span>
|
||||
@@ -167,7 +169,7 @@ if (wechatShareQrEnabled) {
|
||||
{stats.length > 0 ? (
|
||||
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}>
|
||||
{stats.slice(0, 4).map((item) => (
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
|
||||
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_96%,transparent)] px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||
</div>
|
||||
@@ -177,7 +179,7 @@ if (wechatShareQrEnabled) {
|
||||
</div>
|
||||
|
||||
<div class:list={[
|
||||
'rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 shadow-[0_16px_40px_rgba(15,23,42,0.06)]',
|
||||
'rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] shadow-[0_16px_40px_rgba(15,23,42,0.06)]',
|
||||
isCompact ? 'mt-4 p-4' : 'mt-5 p-5',
|
||||
]}>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
|
||||
@@ -186,6 +188,91 @@ if (wechatShareQrEnabled) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isCompact ? (
|
||||
<div class="share-panel-compact-actions mt-4">
|
||||
<div class="share-panel-compact-actions__head">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{isEnglish ? 'Share actions' : '分享操作'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--utility">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-copy-summary
|
||||
data-default-label={copy.copySummary}
|
||||
data-success-label={copy.copySummarySuccess}
|
||||
data-failed-label={copy.copySummaryFailed}
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
<span>{copy.copySummary}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-copy-link
|
||||
data-default-label={copy.copyLink}
|
||||
data-success-label={copy.copyLinkSuccess}
|
||||
data-failed-label={copy.copyLinkFailed}
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{copy.copyLink}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="share-panel-compact-actions__primary">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button terminal-action-button-primary w-full"
|
||||
data-share-summary
|
||||
data-default-label={copy.shareSummary}
|
||||
data-success-label={copy.shareSuccess}
|
||||
data-fallback-label={copy.shareFallback}
|
||||
data-failed-label={copy.shareFailed}
|
||||
>
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
<span>{copy.shareSummary}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--channels">
|
||||
<a
|
||||
href={xShareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="terminal-action-button"
|
||||
data-share-link
|
||||
>
|
||||
<i class="fab fa-twitter"></i>
|
||||
<span>{copy.shareToX}</span>
|
||||
</a>
|
||||
<a
|
||||
href={telegramShareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="terminal-action-button"
|
||||
data-share-link
|
||||
>
|
||||
<i class="fab fa-telegram-plane"></i>
|
||||
<span>{copy.shareToTelegram}</span>
|
||||
</a>
|
||||
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button share-panel-compact-actions__wechat"
|
||||
data-share-wechat-open
|
||||
>
|
||||
<i class="fab fa-weixin"></i>
|
||||
<span>{copy.shareToWeChat}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p class="share-panel-compact-actions__status text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -226,13 +313,13 @@ if (wechatShareQrEnabled) {
|
||||
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
||||
<div class="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] px-4 py-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{isEnglish ? 'Share channels' : '分享渠道'}
|
||||
</p>
|
||||
{!isCompact && <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>}
|
||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
@@ -268,20 +355,24 @@ if (wechatShareQrEnabled) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
|
||||
<div class="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] p-4">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.canonical}</div>
|
||||
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||
</div>
|
||||
|
||||
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||
<div
|
||||
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm"
|
||||
class="share-wechat-modal-overlay fixed inset-0 z-[160] hidden"
|
||||
data-share-wechat-modal
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
|
||||
<div class="share-wechat-modal-card relative isolate w-full max-w-4xl overflow-hidden">
|
||||
<div class="share-wechat-modal-card__line absolute inset-x-0 top-0 h-px"></div>
|
||||
<div class="relative z-[1] p-5 sm:p-7">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker">
|
||||
@@ -298,7 +389,7 @@ if (wechatShareQrEnabled) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="share-wechat-modal-close flex h-11 w-11 items-center justify-center rounded-2xl text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-share-wechat-close
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
@@ -307,19 +398,19 @@ if (wechatShareQrEnabled) {
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
||||
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
|
||||
<div class="share-wechat-modal-surface mx-auto w-full max-w-[260px] rounded-[28px] p-5">
|
||||
<div class="overflow-hidden rounded-2xl bg-white" set:html={wechatShareQrSvg}></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="share-wechat-modal-surface rounded-2xl p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{copy.canonical}
|
||||
</div>
|
||||
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="share-wechat-modal-surface rounded-2xl p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{copy.summaryTitle}
|
||||
</div>
|
||||
@@ -363,6 +454,7 @@ if (wechatShareQrEnabled) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
@@ -393,6 +485,118 @@ if (wechatShareQrEnabled) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style is:inline>
|
||||
.share-panel-compact-actions {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
|
||||
color-mix(in oklab, var(--header-bg) 90%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
0 14px 34px rgba(var(--text-rgb), 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__grid {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__grid--utility {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__grid--channels {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__grid--channels > :last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__primary .terminal-action-button,
|
||||
.share-panel-compact-actions__grid .terminal-action-button {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__grid .terminal-action-button span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.share-panel-compact-actions__status {
|
||||
min-height: 1rem;
|
||||
margin: -0.1rem 0 0;
|
||||
}
|
||||
|
||||
.share-wechat-modal-card {
|
||||
border-radius: 32px;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background-color: var(--terminal-bg);
|
||||
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.36);
|
||||
}
|
||||
|
||||
.share-wechat-modal-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--terminal-bg) 92%, var(--bg)),
|
||||
color-mix(in oklab, var(--terminal-bg) 84%, var(--bg))
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--primary-rgb), 0.055),
|
||||
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.03) 46%,
|
||||
transparent 82%
|
||||
);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.share-wechat-modal-overlay {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.share-wechat-modal-card__line {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--primary-rgb), 0.4), transparent);
|
||||
}
|
||||
|
||||
.share-wechat-modal-close {
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--terminal-bg) 97%, white 3%);
|
||||
box-shadow:
|
||||
0 10px 24px rgba(var(--text-rgb), 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.share-wechat-modal-surface {
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||
background-color: color-mix(in oklab, var(--terminal-bg) 90%, var(--bg));
|
||||
box-shadow:
|
||||
0 16px 36px rgba(var(--text-rgb), 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
@@ -424,6 +628,10 @@ if (wechatShareQrEnabled) {
|
||||
const toastMessage = root.querySelector('[data-share-toast-message]');
|
||||
let toastTimer = 0;
|
||||
|
||||
if (wechatModal instanceof HTMLElement && wechatModal.parentElement !== document.body) {
|
||||
document.body.appendChild(wechatModal);
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
|
||||
@@ -150,7 +150,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>{title}</title>
|
||||
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
|
||||
{jsonLd && <script is:inline type="application/ld+json" set:html={jsonLd}></script>}
|
||||
<slot name="head" />
|
||||
|
||||
<style is:inline>
|
||||
@@ -467,8 +467,6 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
@@ -478,8 +476,6 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
>
|
||||
<noscript>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
@@ -505,7 +501,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
|
||||
<Header siteSettings={siteSettings} />
|
||||
|
||||
<main class="flex-1 w-full">
|
||||
<main class="flex-1 w-full pb-[calc(5.5rem+env(safe-area-inset-bottom))] lg:pb-0">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -8,27 +8,29 @@ import type {
|
||||
PopularPostHighlight,
|
||||
SiteSettings,
|
||||
Tag as UiTag,
|
||||
} from '../types';
|
||||
} from "../types";
|
||||
|
||||
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
|
||||
const PROD_DEFAULT_API_PORT = '5150';
|
||||
const DEV_API_BASE_URL = "http://127.0.0.1:5150/api";
|
||||
const PROD_DEFAULT_API_PORT = "5150";
|
||||
|
||||
function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
return value?.trim().replace(/\/$/, "") ?? "";
|
||||
}
|
||||
|
||||
function getRuntimeEnv(
|
||||
name:
|
||||
| 'PUBLIC_API_BASE_URL'
|
||||
| 'INTERNAL_API_BASE_URL'
|
||||
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
|
||||
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
|
||||
| "PUBLIC_API_BASE_URL"
|
||||
| "INTERNAL_API_BASE_URL"
|
||||
| "PUBLIC_COMMENT_TURNSTILE_SITE_KEY"
|
||||
| "PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY",
|
||||
) {
|
||||
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||
const runtimeProcess = (
|
||||
globalThis as typeof globalThis & {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
}).process;
|
||||
}
|
||||
).process;
|
||||
|
||||
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
|
||||
}
|
||||
@@ -41,28 +43,30 @@ function normalizeVerificationMode(
|
||||
value: string | null | undefined,
|
||||
fallback: HumanVerificationMode,
|
||||
): HumanVerificationMode {
|
||||
switch ((value ?? '').trim().toLowerCase()) {
|
||||
case 'off':
|
||||
return 'off';
|
||||
case 'captcha':
|
||||
case 'normal':
|
||||
case 'simple':
|
||||
return 'captcha';
|
||||
case 'turnstile':
|
||||
return 'turnstile';
|
||||
switch ((value ?? "").trim().toLowerCase()) {
|
||||
case "off":
|
||||
return "off";
|
||||
case "captcha":
|
||||
case "normal":
|
||||
case "simple":
|
||||
return "captcha";
|
||||
case "turnstile":
|
||||
return "turnstile";
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(
|
||||
import.meta.env.PUBLIC_API_BASE_URL,
|
||||
);
|
||||
const buildTimeCommentTurnstileSiteKey =
|
||||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
|
||||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? "";
|
||||
const buildTimeWebPushVapidPublicKey =
|
||||
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
|
||||
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? "";
|
||||
|
||||
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv("PUBLIC_API_BASE_URL");
|
||||
if (runtimePublicApiBaseUrl) {
|
||||
return runtimePublicApiBaseUrl;
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
}
|
||||
|
||||
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
|
||||
const runtimeInternalApiBaseUrl = getRuntimeEnv("INTERNAL_API_BASE_URL");
|
||||
if (runtimeInternalApiBaseUrl) {
|
||||
return runtimeInternalApiBaseUrl;
|
||||
}
|
||||
@@ -94,13 +98,15 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
|
||||
export function resolvePublicCommentTurnstileSiteKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
|
||||
getRuntimeEnv("PUBLIC_COMMENT_TURNSTILE_SITE_KEY") ||
|
||||
buildTimeCommentTurnstileSiteKey
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePublicWebPushVapidPublicKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
|
||||
getRuntimeEnv("PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY") ||
|
||||
buildTimeWebPushVapidPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,12 +120,12 @@ export interface ApiPost {
|
||||
content: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
post_type: 'article' | 'tweet';
|
||||
post_type: "article" | "tweet";
|
||||
image: string | null;
|
||||
images: string[] | null;
|
||||
pinned: boolean;
|
||||
status: string | null;
|
||||
visibility: 'public' | 'unlisted' | 'private' | null;
|
||||
visibility: "public" | "unlisted" | "private" | null;
|
||||
publish_at: string | null;
|
||||
unpublish_at: string | null;
|
||||
canonical_url: string | null;
|
||||
@@ -144,7 +150,7 @@ export interface Comment {
|
||||
content: string | null;
|
||||
reply_to: string | null;
|
||||
reply_to_comment_id: number | null;
|
||||
scope: 'article' | 'paragraph';
|
||||
scope: "article" | "paragraph";
|
||||
paragraph_key: string | null;
|
||||
paragraph_excerpt: string | null;
|
||||
approved: boolean | null;
|
||||
@@ -157,7 +163,7 @@ export interface CreateCommentInput {
|
||||
nickname: string;
|
||||
email?: string;
|
||||
content: string;
|
||||
scope?: 'article' | 'paragraph';
|
||||
scope?: "article" | "paragraph";
|
||||
paragraphKey?: string;
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
@@ -210,7 +216,7 @@ export interface ApiFriendLink {
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
status: "pending" | "approved" | "rejected";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -300,7 +306,7 @@ export interface ApiSiteSettings {
|
||||
}
|
||||
|
||||
export interface ContentAnalyticsInput {
|
||||
eventType: 'page_view' | 'read_progress' | 'read_complete';
|
||||
eventType: "page_view" | "read_progress" | "read_complete";
|
||||
path: string;
|
||||
postSlug?: string;
|
||||
sessionId?: string;
|
||||
@@ -379,7 +385,7 @@ export interface ApiSearchResult {
|
||||
content: string | null;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
post_type: 'article' | 'tweet' | null;
|
||||
post_type: "article" | "tweet" | null;
|
||||
image: string | null;
|
||||
pinned: boolean | null;
|
||||
created_at: string;
|
||||
@@ -404,10 +410,10 @@ export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult
|
||||
export interface Review {
|
||||
id: number;
|
||||
title: string;
|
||||
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
|
||||
review_type: "game" | "anime" | "music" | "book" | "movie";
|
||||
rating: number;
|
||||
review_date: string;
|
||||
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped';
|
||||
status: "published" | "draft" | "completed" | "in-progress" | "dropped";
|
||||
description: string;
|
||||
tags: string;
|
||||
cover: string;
|
||||
@@ -417,59 +423,60 @@ export interface Review {
|
||||
}
|
||||
|
||||
export type AppFriendLink = UiFriendLink & {
|
||||
status: ApiFriendLink['status'];
|
||||
status: ApiFriendLink["status"];
|
||||
};
|
||||
|
||||
export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
id: '1',
|
||||
siteName: 'InitCool',
|
||||
siteShortName: 'Termi',
|
||||
siteUrl: 'https://init.cool',
|
||||
siteTitle: 'InitCool · 技术笔记与内容档案',
|
||||
siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。',
|
||||
heroTitle: '欢迎来到 InitCool',
|
||||
heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。',
|
||||
ownerName: 'InitCool',
|
||||
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
|
||||
ownerBio: 'InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
|
||||
location: 'Hong Kong',
|
||||
id: "1",
|
||||
siteName: "InitCool",
|
||||
siteShortName: "Termi",
|
||||
siteUrl: "https://init.cool",
|
||||
siteTitle: "InitCool · 技术笔记与内容档案",
|
||||
siteDescription: "一个认真折腾、偶尔整活的小站。",
|
||||
heroTitle: "欢迎光临,先随便翻翻",
|
||||
heroSubtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
|
||||
ownerName: "InitCool",
|
||||
ownerTitle: "负责把脑洞拧成页面的人",
|
||||
ownerBio:
|
||||
"一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。",
|
||||
location: "Hong Kong",
|
||||
social: {
|
||||
github: 'https://github.com/limitcool',
|
||||
twitter: '',
|
||||
email: 'mailto:initcoool@gmail.com',
|
||||
github: "https://github.com/limitcool",
|
||||
twitter: "",
|
||||
email: "mailto:initcoool@gmail.com",
|
||||
},
|
||||
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
|
||||
techStack: ["Rust", "Go", "Python", "Svelte", "Astro", "Loco.rs"],
|
||||
musicEnabled: true,
|
||||
musicPlaylist: [
|
||||
{
|
||||
title: '山中来信',
|
||||
artist: 'InitCool Radio',
|
||||
album: '站点默认歌单',
|
||||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||||
title: "山中来信",
|
||||
artist: "InitCool Radio",
|
||||
album: "站点默认歌单",
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
|
||||
coverImageUrl:
|
||||
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80',
|
||||
accentColor: '#2f6b5f',
|
||||
description: '适合文章阅读时循环播放的轻氛围曲。',
|
||||
"https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80",
|
||||
accentColor: "#2f6b5f",
|
||||
description: "适合文章阅读时循环播放的轻氛围曲。",
|
||||
},
|
||||
{
|
||||
title: '风吹松声',
|
||||
artist: 'InitCool Radio',
|
||||
album: '站点默认歌单',
|
||||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
|
||||
title: "风吹松声",
|
||||
artist: "InitCool Radio",
|
||||
album: "站点默认歌单",
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
|
||||
coverImageUrl:
|
||||
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80',
|
||||
accentColor: '#8a5b35',
|
||||
description: '偏木质感的器乐氛围,适合深夜浏览。',
|
||||
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80",
|
||||
accentColor: "#8a5b35",
|
||||
description: "偏木质感的器乐氛围,适合深夜浏览。",
|
||||
},
|
||||
{
|
||||
title: '夜航小记',
|
||||
artist: 'InitCool Radio',
|
||||
album: '站点默认歌单',
|
||||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
|
||||
title: "夜航小记",
|
||||
artist: "InitCool Radio",
|
||||
album: "站点默认歌单",
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
|
||||
coverImageUrl:
|
||||
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80',
|
||||
accentColor: '#375a7f',
|
||||
description: '节奏更明显一点,适合切换阅读状态。',
|
||||
"https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80",
|
||||
accentColor: "#375a7f",
|
||||
description: "节奏更明显一点,适合切换阅读状态。",
|
||||
},
|
||||
],
|
||||
ai: {
|
||||
@@ -477,16 +484,17 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
verificationMode: 'captcha',
|
||||
verificationMode: "captcha",
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled: true,
|
||||
popupTitle: '订阅更新',
|
||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||
popupTitle: "订阅更新",
|
||||
popupDescription:
|
||||
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。",
|
||||
popupDelaySeconds: 18,
|
||||
verificationMode: 'off',
|
||||
verificationMode: "off",
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
webPushEnabled: false,
|
||||
@@ -503,7 +511,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||||
|
||||
const estimateReadTime = (content: string | null | undefined) => {
|
||||
const text = content?.trim() || '';
|
||||
const text = content?.trim() || "";
|
||||
const minutes = Math.max(1, Math.ceil(text.length / 300));
|
||||
return `${minutes} 分钟`;
|
||||
};
|
||||
@@ -567,12 +575,12 @@ const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||||
try {
|
||||
const host = new URL(value).hostname.toLowerCase();
|
||||
const isReservedExampleHost =
|
||||
host === 'example.com' ||
|
||||
host === 'example.org' ||
|
||||
host === 'example.net' ||
|
||||
host.endsWith('.example.com') ||
|
||||
host.endsWith('.example.org') ||
|
||||
host.endsWith('.example.net');
|
||||
host === "example.com" ||
|
||||
host === "example.org" ||
|
||||
host === "example.net" ||
|
||||
host.endsWith(".example.com") ||
|
||||
host.endsWith(".example.org") ||
|
||||
host.endsWith(".example.net");
|
||||
|
||||
return isReservedExampleHost ? undefined : value;
|
||||
} catch {
|
||||
@@ -595,15 +603,16 @@ const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
|
||||
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
const commentVerificationMode = normalizeVerificationMode(
|
||||
settings.comment_verification_mode,
|
||||
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha',
|
||||
settings.comment_turnstile_enabled ? "turnstile" : "captcha",
|
||||
);
|
||||
const subscriptionVerificationMode = normalizeVerificationMode(
|
||||
settings.subscription_verification_mode,
|
||||
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
|
||||
settings.subscription_turnstile_enabled ? "turnstile" : "off",
|
||||
);
|
||||
const musicEnabled = settings.music_enabled ?? true;
|
||||
const normalizedMusicPlaylist =
|
||||
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
|
||||
const normalizedMusicPlaylist = settings.music_playlist?.filter(
|
||||
(item) => item?.title?.trim() && item?.url?.trim(),
|
||||
)?.length
|
||||
? settings.music_playlist
|
||||
.filter((item) => item.title.trim() && item.url.trim())
|
||||
.map((item) => ({
|
||||
@@ -620,10 +629,12 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
return {
|
||||
id: String(settings.id),
|
||||
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
|
||||
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
|
||||
siteShortName:
|
||||
settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
|
||||
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
|
||||
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
|
||||
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
|
||||
siteDescription:
|
||||
settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
|
||||
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
|
||||
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
|
||||
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
|
||||
@@ -636,7 +647,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
|
||||
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
|
||||
},
|
||||
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
|
||||
techStack: settings.tech_stack?.length
|
||||
? settings.tech_stack
|
||||
: DEFAULT_SITE_SETTINGS.techStack,
|
||||
musicEnabled,
|
||||
musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
|
||||
ai: {
|
||||
@@ -645,15 +658,19 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
comments: {
|
||||
verificationMode: commentVerificationMode,
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
turnstileEnabled: commentVerificationMode === 'turnstile',
|
||||
turnstileEnabled: commentVerificationMode === "turnstile",
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
settings.turnstile_site_key ||
|
||||
resolvePublicCommentTurnstileSiteKey() ||
|
||||
undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled:
|
||||
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
|
||||
settings.subscription_popup_enabled ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
|
||||
popupTitle:
|
||||
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
|
||||
settings.subscription_popup_title ||
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
|
||||
popupDescription:
|
||||
settings.subscription_popup_description ||
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
|
||||
@@ -661,9 +678,11 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
settings.subscription_popup_delay_seconds ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
||||
verificationMode: subscriptionVerificationMode,
|
||||
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
|
||||
turnstileEnabled: subscriptionVerificationMode === "turnstile",
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
settings.turnstile_site_key ||
|
||||
resolvePublicCommentTurnstileSiteKey() ||
|
||||
undefined,
|
||||
webPushEnabled: Boolean(settings.web_push_enabled),
|
||||
webPushVapidPublicKey:
|
||||
settings.web_push_vapid_public_key ||
|
||||
@@ -680,7 +699,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
};
|
||||
|
||||
const normalizeContentOverview = (
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
overview: ApiHomePagePayload["content_overview"] | undefined,
|
||||
): ContentOverview => ({
|
||||
totalPageViews: overview?.total_page_views ?? 0,
|
||||
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
|
||||
@@ -692,9 +711,9 @@ const normalizeContentOverview = (
|
||||
});
|
||||
|
||||
const CONTENT_WINDOW_META = [
|
||||
{ key: '24h', label: '24h', days: 1 },
|
||||
{ key: '7d', label: '7d', days: 7 },
|
||||
{ key: '30d', label: '30d', days: 30 },
|
||||
{ key: "24h", label: "24h", days: 1 },
|
||||
{ key: "7d", label: "7d", days: 7 },
|
||||
{ key: "30d", label: "30d", days: 30 },
|
||||
] as const;
|
||||
|
||||
const normalizePopularPost = (
|
||||
@@ -718,9 +737,9 @@ const normalizePopularPost = (
|
||||
});
|
||||
|
||||
const normalizeContentRanges = (
|
||||
ranges: ApiHomePagePayload['content_ranges'] | undefined,
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
|
||||
ranges: ApiHomePagePayload["content_ranges"] | undefined,
|
||||
overview: ApiHomePagePayload["content_overview"] | undefined,
|
||||
popularPosts: ApiHomePagePayload["popular_posts"] | undefined,
|
||||
postsBySlug: Map<string, UiPost>,
|
||||
): ContentWindowHighlight[] => {
|
||||
const normalizedRanges = new Map(
|
||||
@@ -749,7 +768,7 @@ const normalizeContentRanges = (
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (meta.key === '7d') {
|
||||
if (meta.key === "7d") {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
@@ -758,13 +777,16 @@ const normalizeContentRanges = (
|
||||
pageViews: overview?.page_views_last_7d ?? 0,
|
||||
readCompletes: overview?.read_completes_last_7d ?? 0,
|
||||
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
|
||||
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
avgReadDurationMs:
|
||||
overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
},
|
||||
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
|
||||
popularPosts: (popularPosts ?? []).map((item) =>
|
||||
normalizePopularPost(item, postsBySlug),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.key === '24h') {
|
||||
if (meta.key === "24h") {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
@@ -805,14 +827,16 @@ class ApiClient {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
errorText || `API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
@@ -823,7 +847,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getRawPosts(): Promise<ApiPost[]> {
|
||||
return this.fetch<ApiPost[]>('/posts');
|
||||
return this.fetch<ApiPost[]>("/posts");
|
||||
}
|
||||
|
||||
async getPosts(): Promise<UiPost[]> {
|
||||
@@ -850,16 +874,18 @@ class ApiClient {
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.page) params.set('page', String(options.page));
|
||||
if (options?.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options?.search) params.set('search', options.search);
|
||||
if (options?.category) params.set('category', options.category);
|
||||
if (options?.tag) params.set('tag', options.tag);
|
||||
if (options?.postType) params.set('type', options.postType);
|
||||
if (options?.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
if (options?.page) params.set("page", String(options.page));
|
||||
if (options?.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options?.search) params.set("search", options.search);
|
||||
if (options?.category) params.set("category", options.category);
|
||||
if (options?.tag) params.set("tag", options.tag);
|
||||
if (options?.postType) params.set("type", options.postType);
|
||||
if (options?.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options?.sortOrder) params.set("sort_order", options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
|
||||
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(
|
||||
`/posts/page?${params.toString()}`,
|
||||
);
|
||||
return {
|
||||
items: payload.items.map(normalizePost),
|
||||
page: payload.page,
|
||||
@@ -877,27 +903,32 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getPostBySlug(slug: string): Promise<UiPost | null> {
|
||||
const response = await fetch(`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`, {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
errorText || `API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizePost((await response.json()) as ApiPost);
|
||||
}
|
||||
|
||||
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
|
||||
await this.fetch<{ recorded: boolean }>('/analytics/content', {
|
||||
method: 'POST',
|
||||
await this.fetch<{ recorded: boolean }>("/analytics/content", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event_type: input.eventType,
|
||||
path: input.path,
|
||||
@@ -908,38 +939,42 @@ class ApiClient {
|
||||
metadata: input.metadata,
|
||||
referrer: input.referrer,
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async getComments(
|
||||
postSlug: string,
|
||||
options?: {
|
||||
approved?: boolean;
|
||||
scope?: 'article' | 'paragraph';
|
||||
scope?: "article" | "paragraph";
|
||||
paragraphKey?: string;
|
||||
}
|
||||
},
|
||||
): Promise<Comment[]> {
|
||||
const params = new URLSearchParams({ post_slug: postSlug });
|
||||
if (options?.approved !== undefined) {
|
||||
params.set('approved', String(options.approved));
|
||||
params.set("approved", String(options.approved));
|
||||
}
|
||||
if (options?.scope) {
|
||||
params.set('scope', options.scope);
|
||||
params.set("scope", options.scope);
|
||||
}
|
||||
if (options?.paragraphKey) {
|
||||
params.set('paragraph_key', options.paragraphKey);
|
||||
params.set("paragraph_key", options.paragraphKey);
|
||||
}
|
||||
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
|
||||
async getParagraphCommentSummary(
|
||||
postSlug: string,
|
||||
): Promise<ParagraphCommentSummary[]> {
|
||||
const params = new URLSearchParams({ post_slug: postSlug });
|
||||
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
|
||||
return this.fetch<ParagraphCommentSummary[]>(
|
||||
`/comments/paragraphs/summary?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createComment(comment: CreateCommentInput): Promise<Comment> {
|
||||
return this.fetch<Comment>('/comments', {
|
||||
method: 'POST',
|
||||
return this.fetch<Comment>("/comments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
postSlug: comment.postSlug,
|
||||
nickname: comment.nickname,
|
||||
@@ -959,11 +994,11 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
|
||||
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
|
||||
return this.fetch<CommentCaptchaChallenge>("/comments/captcha");
|
||||
}
|
||||
|
||||
async getReviews(): Promise<Review[]> {
|
||||
return this.fetch<Review[]>('/reviews');
|
||||
return this.fetch<Review[]>("/reviews");
|
||||
}
|
||||
|
||||
async getReview(id: number): Promise<Review> {
|
||||
@@ -971,7 +1006,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getRawFriendLinks(): Promise<ApiFriendLink[]> {
|
||||
return this.fetch<ApiFriendLink[]>('/friend_links');
|
||||
return this.fetch<ApiFriendLink[]>("/friend_links");
|
||||
}
|
||||
|
||||
async getFriendLinks(): Promise<AppFriendLink[]> {
|
||||
@@ -979,9 +1014,11 @@ class ApiClient {
|
||||
return friendLinks.map(normalizeFriendLink);
|
||||
}
|
||||
|
||||
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> {
|
||||
return this.fetch<ApiFriendLink>('/friend_links', {
|
||||
method: 'POST',
|
||||
async createFriendLink(
|
||||
friendLink: CreateFriendLinkInput,
|
||||
): Promise<ApiFriendLink> {
|
||||
return this.fetch<ApiFriendLink>("/friend_links", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(friendLink),
|
||||
});
|
||||
}
|
||||
@@ -994,8 +1031,8 @@ class ApiClient {
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
}): Promise<PublicSubscriptionResponse> {
|
||||
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
|
||||
method: 'POST',
|
||||
return this.fetch<PublicSubscriptionResponse>("/subscriptions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
@@ -1007,14 +1044,21 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
|
||||
method: 'POST',
|
||||
async confirmSubscription(
|
||||
token: string,
|
||||
): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
"/subscriptions/confirm",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
async getManagedSubscription(
|
||||
token: string,
|
||||
): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
@@ -1026,26 +1070,34 @@ class ApiClient {
|
||||
status?: string | null;
|
||||
filters?: Record<string, unknown> | null;
|
||||
}): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
|
||||
method: 'PATCH',
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
"/subscriptions/manage",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
token: input.token,
|
||||
displayName: input.displayName,
|
||||
status: input.status,
|
||||
filters: input.filters,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
|
||||
method: 'POST',
|
||||
async unsubscribeSubscription(
|
||||
token: string,
|
||||
): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
"/subscriptions/unsubscribe",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getRawTags(): Promise<ApiTag[]> {
|
||||
return this.fetch<ApiTag[]>('/tags');
|
||||
return this.fetch<ApiTag[]>("/tags");
|
||||
}
|
||||
|
||||
async getTags(): Promise<UiTag[]> {
|
||||
@@ -1054,7 +1106,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getRawSiteSettings(): Promise<ApiSiteSettings> {
|
||||
return this.fetch<ApiSiteSettings>('/site_settings');
|
||||
return this.fetch<ApiSiteSettings>("/site_settings");
|
||||
}
|
||||
|
||||
async getSiteSettings(): Promise<SiteSettings> {
|
||||
@@ -1072,7 +1124,7 @@ class ApiClient {
|
||||
contentRanges: ContentWindowHighlight[];
|
||||
popularPosts: PopularPostHighlight[];
|
||||
}> {
|
||||
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
|
||||
const payload = await this.fetch<ApiHomePagePayload>("/site_settings/home");
|
||||
const posts = (payload.posts ?? []).map(normalizePost);
|
||||
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
|
||||
const popularPosts = (payload.popular_posts ?? []).map((item) =>
|
||||
@@ -1097,20 +1149,22 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getCategories(): Promise<UiCategory[]> {
|
||||
const categories = await this.fetch<ApiCategory[]>('/categories');
|
||||
const categories = await this.fetch<ApiCategory[]>("/categories");
|
||||
return categories.map(normalizeCategory);
|
||||
}
|
||||
|
||||
async getPostsByCategory(category: string): Promise<UiPost[]> {
|
||||
const posts = await this.getPosts();
|
||||
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase());
|
||||
return posts.filter(
|
||||
(post) => post.category?.toLowerCase() === category.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
async getPostsByTag(tag: string): Promise<UiPost[]> {
|
||||
const posts = await this.getPosts();
|
||||
const normalizedTag = normalizeTagToken(tag);
|
||||
return posts.filter(post =>
|
||||
post.tags?.some(item => normalizeTagToken(item) === normalizedTag)
|
||||
return posts.filter((post) =>
|
||||
post.tags?.some((item) => normalizeTagToken(item) === normalizedTag),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1119,18 +1173,20 @@ class ApiClient {
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
});
|
||||
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`);
|
||||
const results = await this.fetch<ApiSearchResult[]>(
|
||||
`/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return results.map(result =>
|
||||
return results.map((result) =>
|
||||
normalizePost({
|
||||
id: result.id,
|
||||
title: result.title || 'Untitled',
|
||||
title: result.title || "Untitled",
|
||||
slug: result.slug,
|
||||
description: result.description || '',
|
||||
content: result.content || '',
|
||||
category: result.category || '',
|
||||
description: result.description || "",
|
||||
content: result.content || "",
|
||||
category: result.category || "",
|
||||
tags: result.tags ?? [],
|
||||
post_type: result.post_type || 'article',
|
||||
post_type: result.post_type || "article",
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
@@ -1145,7 +1201,7 @@ class ApiClient {
|
||||
redirect_to: null,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1169,27 +1225,29 @@ class ApiClient {
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams({ q: options.query });
|
||||
if (options.page) params.set('page', String(options.page));
|
||||
if (options.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options.category) params.set('category', options.category);
|
||||
if (options.tag) params.set('tag', options.tag);
|
||||
if (options.postType) params.set('type', options.postType);
|
||||
if (options.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.category) params.set("category", options.category);
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
if (options.postType) params.set("type", options.postType);
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortOrder) params.set("sort_order", options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
|
||||
const payload = await this.fetch<ApiPagedSearchResponse>(
|
||||
`/search/page?${params.toString()}`,
|
||||
);
|
||||
return {
|
||||
query: payload.query,
|
||||
items: payload.items.map((result) =>
|
||||
normalizePost({
|
||||
id: result.id,
|
||||
title: result.title || 'Untitled',
|
||||
title: result.title || "Untitled",
|
||||
slug: result.slug,
|
||||
description: result.description || '',
|
||||
content: result.content || '',
|
||||
category: result.category || '',
|
||||
description: result.description || "",
|
||||
content: result.content || "",
|
||||
category: result.category || "",
|
||||
tags: result.tags ?? [],
|
||||
post_type: result.post_type || 'article',
|
||||
post_type: result.post_type || "article",
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
@@ -1216,15 +1274,20 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async askAi(question: string): Promise<AiAskResponse> {
|
||||
return this.fetch<AiAskResponse>('/ai/ask', {
|
||||
method: 'POST',
|
||||
return this.fetch<AiAskResponse>("/ai/ask", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
|
||||
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
|
||||
export function createApiClient(options?: {
|
||||
baseUrl?: string;
|
||||
requestUrl?: string | URL;
|
||||
}) {
|
||||
return new ApiClient(
|
||||
options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl),
|
||||
);
|
||||
}
|
||||
|
||||
export const api = createApiClient();
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface TerminalConfig {
|
||||
description: string;
|
||||
date: string;
|
||||
readTime: string;
|
||||
type: 'article' | 'tweet';
|
||||
type: "article" | "tweet";
|
||||
tags: string[];
|
||||
link: string;
|
||||
};
|
||||
@@ -83,14 +83,14 @@ export interface TerminalConfig {
|
||||
}
|
||||
|
||||
export const terminalConfig: TerminalConfig = {
|
||||
defaultCategory: 'blog',
|
||||
welcomeMessage: '欢迎来到 InitCool!',
|
||||
defaultCategory: "blog",
|
||||
welcomeMessage: "欢迎来到 InitCool!",
|
||||
prompt: {
|
||||
prefix: 'user@blog',
|
||||
separator: ':',
|
||||
path: '~/',
|
||||
suffix: '$',
|
||||
mobile: '~$'
|
||||
prefix: "user@blog",
|
||||
separator: ":",
|
||||
path: "~/",
|
||||
suffix: "$",
|
||||
mobile: "~$",
|
||||
},
|
||||
asciiArt: `
|
||||
I N N I TTTTT CCCC OOO OOO L
|
||||
@@ -98,70 +98,70 @@ I NN N I T C O O O O L
|
||||
I N N N I T C O O O O L
|
||||
I N NN I T C O O O O L
|
||||
I N N I T CCCC OOO OOO LLLLL`,
|
||||
title: '~/blog',
|
||||
title: "~/blog",
|
||||
welcome: {
|
||||
title: '欢迎来到 InitCool',
|
||||
subtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。'
|
||||
title: "欢迎来到 InitCool",
|
||||
subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
|
||||
},
|
||||
navLinks: [
|
||||
{ icon: 'fa-file-code', text: '文章', href: '/articles' },
|
||||
{ icon: 'fa-folder', text: '分类', href: '/categories' },
|
||||
{ icon: 'fa-tags', text: '标签', href: '/tags' },
|
||||
{ icon: 'fa-stream', text: '时间轴', href: '/timeline' },
|
||||
{ icon: 'fa-star', text: '评价', href: '/reviews' },
|
||||
{ icon: 'fa-link', text: '友链', href: '/friends' },
|
||||
{ icon: 'fa-user-secret', text: '关于', href: '/about' }
|
||||
{ icon: "fa-file-code", text: "文章", href: "/articles" },
|
||||
{ icon: "fa-folder", text: "分类", href: "/categories" },
|
||||
{ icon: "fa-tags", text: "标签", href: "/tags" },
|
||||
{ icon: "fa-stream", text: "时间轴", href: "/timeline" },
|
||||
{ icon: "fa-star", text: "评价", href: "/reviews" },
|
||||
{ icon: "fa-link", text: "友链", href: "/friends" },
|
||||
{ icon: "fa-user-secret", text: "关于", href: "/about" },
|
||||
],
|
||||
categories: {
|
||||
blog: {
|
||||
title: '博客',
|
||||
description: '我的个人博客文章',
|
||||
title: "博客",
|
||||
description: "我的个人博客文章",
|
||||
items: [
|
||||
{
|
||||
command: 'help',
|
||||
description: '显示帮助信息',
|
||||
shortDesc: '显示帮助信息'
|
||||
}
|
||||
]
|
||||
}
|
||||
command: "help",
|
||||
description: "显示帮助信息",
|
||||
shortDesc: "显示帮助信息",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postTypes: {
|
||||
article: { color: '#00ff9d', label: '博客文章' },
|
||||
tweet: { color: '#00b8ff', label: '推文' }
|
||||
article: { color: "#00ff9d", label: "博客文章" },
|
||||
tweet: { color: "#00b8ff", label: "推文" },
|
||||
},
|
||||
socialLinks: {
|
||||
github: '',
|
||||
twitter: '',
|
||||
email: ''
|
||||
github: "",
|
||||
twitter: "",
|
||||
email: "",
|
||||
},
|
||||
tools: [
|
||||
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' },
|
||||
{ icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' }
|
||||
{ icon: "fa-sitemap", href: "/sitemap.xml", title: "站点地图" },
|
||||
{ icon: "fa-rss", href: "/rss.xml", title: "RSS订阅" },
|
||||
],
|
||||
search: {
|
||||
placeholders: {
|
||||
default: "'关键词' 文章 / 标签 / 分类",
|
||||
small: "搜索...",
|
||||
medium: "搜索文章..."
|
||||
medium: "搜索文章...",
|
||||
},
|
||||
promptText: "搜索",
|
||||
emptyResultText: "输入关键词搜索文章"
|
||||
emptyResultText: "输入关键词搜索文章",
|
||||
},
|
||||
terminal: {
|
||||
defaultWindowTitle: 'user@terminal: ~/blog',
|
||||
defaultWindowTitle: "user@terminal: ~/blog",
|
||||
controls: {
|
||||
colors: {
|
||||
close: '#ff5f56',
|
||||
minimize: '#ffbd2e',
|
||||
expand: '#27c93f'
|
||||
}
|
||||
close: "#ff5f56",
|
||||
minimize: "#ffbd2e",
|
||||
expand: "#27c93f",
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
glowDuration: '4s'
|
||||
}
|
||||
glowDuration: "4s",
|
||||
},
|
||||
},
|
||||
branding: {
|
||||
name: 'InitCool',
|
||||
shortName: 'Termi'
|
||||
}
|
||||
name: "InitCool",
|
||||
shortName: "Termi",
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@ export interface DiscoveryFaqOptions {
|
||||
signals?: string[];
|
||||
}
|
||||
|
||||
export type JsonLdObject = Record<string, unknown>;
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -280,6 +282,12 @@ export function buildFaqJsonLd(faqs: ArticleFaqItem[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export function compactJsonLd<T extends JsonLdObject>(
|
||||
items: Array<T | null | undefined | false>,
|
||||
): T[] {
|
||||
return items.filter((item): item is T => Boolean(item));
|
||||
}
|
||||
|
||||
export function buildPostItemList(posts: Post[], siteUrl: string) {
|
||||
return posts.map((post, index) => ({
|
||||
'@type': 'ListItem',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
|
||||
const SERVICE_WORKER_URL = "/termi-web-push-sw.js";
|
||||
|
||||
export type BrowserPushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
@@ -9,21 +9,26 @@ export type BrowserPushSubscriptionPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
export type BrowserPushSubscriptionState = {
|
||||
subscription: PushSubscription | null;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
function ensureBrowserSupport() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('Notification' in window) ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
!('PushManager' in window)
|
||||
typeof window === "undefined" ||
|
||||
!("Notification" in window) ||
|
||||
!("serviceWorker" in navigator) ||
|
||||
!("PushManager" in window)
|
||||
) {
|
||||
throw new Error('当前浏览器不支持 Web Push。');
|
||||
throw new Error("当前浏览器不支持 Web Push。");
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const normalized = base64String.trim();
|
||||
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||
const base64 = (normalized + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binary = window.atob(base64);
|
||||
const output = new Uint8Array(binary.length);
|
||||
|
||||
@@ -34,22 +39,66 @@ function urlBase64ToUint8Array(base64String: string) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function toUint8Array(value: BufferSource | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
|
||||
function uint8ArraysEqual(left: Uint8Array | null, right: Uint8Array | null) {
|
||||
if (!left || !right || left.byteLength !== right.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < left.byteLength; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function subscriptionMatchesPublicKey(
|
||||
subscription: PushSubscription,
|
||||
publicKeyBytes: Uint8Array | null,
|
||||
) {
|
||||
if (!publicKeyBytes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentKey = toUint8Array(
|
||||
subscription.options?.applicationServerKey ?? null,
|
||||
);
|
||||
return uint8ArraysEqual(currentKey, publicKeyBytes);
|
||||
}
|
||||
|
||||
async function getRegistration() {
|
||||
ensureBrowserSupport();
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" });
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
function normalizeSubscription(
|
||||
subscription: PushSubscription | PushSubscriptionJSON,
|
||||
): BrowserPushSubscriptionPayload {
|
||||
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
|
||||
const endpoint = json.endpoint?.trim() || '';
|
||||
const auth = json.keys?.auth?.trim() || '';
|
||||
const p256dh = json.keys?.p256dh?.trim() || '';
|
||||
const json = "toJSON" in subscription ? subscription.toJSON() : subscription;
|
||||
const endpoint = json.endpoint?.trim() || "";
|
||||
const auth = json.keys?.auth?.trim() || "";
|
||||
const p256dh = json.keys?.p256dh?.trim() || "";
|
||||
|
||||
if (!endpoint || !auth || !p256dh) {
|
||||
throw new Error('浏览器返回的 PushSubscription 不完整。');
|
||||
throw new Error("浏览器返回的 PushSubscription 不完整。");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -64,20 +113,53 @@ function normalizeSubscription(
|
||||
|
||||
export function supportsBrowserPush() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'Notification' in window &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window
|
||||
typeof window !== "undefined" &&
|
||||
"Notification" in window &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrowserPushSubscription() {
|
||||
export async function getBrowserPushSubscriptionState(
|
||||
publicKey?: string,
|
||||
): Promise<BrowserPushSubscriptionState> {
|
||||
if (!supportsBrowserPush()) {
|
||||
return null;
|
||||
return {
|
||||
subscription: null,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
return registration.pushManager.getSubscription();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
const normalizedPublicKey = publicKey?.trim() || "";
|
||||
const publicKeyBytes = normalizedPublicKey
|
||||
? urlBase64ToUint8Array(normalizedPublicKey)
|
||||
: null;
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
subscription: null,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!subscriptionMatchesPublicKey(subscription, publicKeyBytes)) {
|
||||
return {
|
||||
subscription: null,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subscription,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBrowserPushSubscription(publicKey?: string) {
|
||||
const state = await getBrowserPushSubscriptionState(publicKey);
|
||||
return state.subscription;
|
||||
}
|
||||
|
||||
export async function ensureBrowserPushSubscription(
|
||||
@@ -86,25 +168,34 @@ export async function ensureBrowserPushSubscription(
|
||||
ensureBrowserSupport();
|
||||
|
||||
if (!publicKey.trim()) {
|
||||
throw new Error('Web Push 公钥未配置。');
|
||||
throw new Error("Web Push 公钥未配置。");
|
||||
}
|
||||
|
||||
const permission =
|
||||
Notification.permission === 'granted'
|
||||
? 'granted'
|
||||
Notification.permission === "granted"
|
||||
? "granted"
|
||||
: await Notification.requestPermission();
|
||||
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('浏览器通知权限未开启。');
|
||||
if (permission !== "granted") {
|
||||
throw new Error("浏览器通知权限未开启。");
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
const publicKeyBytes = urlBase64ToUint8Array(publicKey);
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (
|
||||
subscription &&
|
||||
!subscriptionMatchesPublicKey(subscription, publicKeyBytes)
|
||||
) {
|
||||
await subscription.unsubscribe().catch(() => undefined);
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
applicationServerKey: publicKeyBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import StatsList from '../../components/StatsList.astro';
|
||||
import TechStackList from '../../components/TechStackList.astro';
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -130,7 +130,7 @@ const aboutJsonLd = [
|
||||
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
|
||||
description={siteSettings.siteDescription}
|
||||
siteSettings={siteSettings}
|
||||
jsonLd={aboutJsonLd.filter(Boolean)}
|
||||
jsonLd={compactJsonLd(aboutJsonLd)}
|
||||
>
|
||||
<PageViewTracker pageType="about" entityId="about" />
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
buildArticleHighlights,
|
||||
buildArticlePreviewParagraphs,
|
||||
buildArticleSynopsis,
|
||||
compactJsonLd,
|
||||
resolvePostUpdatedAt,
|
||||
} from '../../lib/seo';
|
||||
import type { PopularPostHighlight } from '../../lib/types';
|
||||
@@ -45,7 +46,6 @@ let post = null;
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`;
|
||||
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
|
||||
let postLookupFailed = false;
|
||||
|
||||
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
|
||||
apiClient.getPostBySlug(slug ?? ''),
|
||||
@@ -56,7 +56,6 @@ const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettle
|
||||
if (postResult.status === 'fulfilled') {
|
||||
post = postResult.value;
|
||||
} else {
|
||||
postLookupFailed = true;
|
||||
console.error('API Error:', postResult.reason);
|
||||
}
|
||||
|
||||
@@ -77,8 +76,8 @@ if (homeDataResult.status === 'fulfilled') {
|
||||
|
||||
if (!post) {
|
||||
return new Response(null, {
|
||||
status: postLookupFailed ? 503 : 404,
|
||||
headers: postLookupFailed ? { 'Retry-After': '120' } : undefined,
|
||||
status: postResult.status !== 'fulfilled' ? 503 : 404,
|
||||
headers: postResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,7 +370,7 @@ const breadcrumbJsonLd = {
|
||||
ogImage={ogImage}
|
||||
ogType="article"
|
||||
twitterCard="summary_large_image"
|
||||
jsonLd={[articleJsonLd, breadcrumbJsonLd, faqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([articleJsonLd, breadcrumbJsonLd, faqJsonLd])}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={publishedAt} />
|
||||
@@ -438,7 +437,7 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<section class="grid items-start gap-5 xl:grid-cols-[minmax(0,1.62fr)_minmax(16.5rem,0.78fr)] 2xl:grid-cols-[minmax(0,1.8fr)_minmax(17rem,0.82fr)]">
|
||||
<div class="relative overflow-hidden rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(var(--secondary-rgb),0.05)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6">
|
||||
<div class="relative overflow-hidden rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)] p-5 shadow-[0_16px_40px_rgba(15,23,42,0.08)] sm:p-6">
|
||||
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
|
||||
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
|
||||
@@ -459,7 +458,7 @@ const breadcrumbJsonLd = {
|
||||
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
|
||||
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
|
||||
<div class="space-y-3">
|
||||
{articlePreviewParagraphs.map((paragraph) => (
|
||||
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
|
||||
@@ -501,7 +500,7 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]">
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
||||
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] px-4 py-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
@@ -548,7 +547,7 @@ const breadcrumbJsonLd = {
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
{digestStats.map((item) => (
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
|
||||
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||
</div>
|
||||
@@ -559,7 +558,7 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
||||
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{articleCopy.sourceTitle}
|
||||
</h3>
|
||||
@@ -602,13 +601,13 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
{articleHighlights.length > 0 && (
|
||||
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
||||
<div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{articleCopy.highlightsTitle}
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
{articleHighlights.map((item, index) => (
|
||||
<div class="flex items-start gap-3 rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
|
||||
<div class="flex items-start gap-3 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_95%,transparent)] px-4 py-3">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
@@ -829,7 +828,7 @@ const breadcrumbJsonLd = {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-article-floating-action="digest-copy"
|
||||
title={articleCopy.copySummary}
|
||||
aria-label={articleCopy.copySummary}
|
||||
@@ -838,7 +837,7 @@ const breadcrumbJsonLd = {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-article-floating-action="digest-share"
|
||||
title={articleCopy.shareSummary}
|
||||
aria-label={articleCopy.shareSummary}
|
||||
@@ -848,7 +847,7 @@ const breadcrumbJsonLd = {
|
||||
{wechatShareQrEnabled && wechatShareQrSvg && (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-article-floating-action="wechat-qr"
|
||||
title={articleCopy.shareToWeChat}
|
||||
aria-label={articleCopy.shareToWeChat}
|
||||
@@ -858,7 +857,7 @@ const breadcrumbJsonLd = {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-article-floating-action="permalink-copy"
|
||||
title={t('common.copyPermalink')}
|
||||
aria-label={t('common.copyPermalink')}
|
||||
@@ -904,19 +903,19 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
||||
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_94%,white_6%),color-mix(in_oklab,var(--header-bg)_92%,white_4%))] p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
||||
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{articleCopy.canonical}
|
||||
</div>
|
||||
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{articleCopy.digestTitle}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList, compactJsonLd } from '../../lib/seo';
|
||||
import type { Category, Post, Tag } from '../../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
|
||||
|
||||
@@ -193,7 +193,7 @@ const buildArticlesUrl = ({
|
||||
siteSettings={siteSettings}
|
||||
canonical={canonicalUrl}
|
||||
noindex={hasActiveFilters}
|
||||
jsonLd={[...jsonLd, articleIndexFaqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([...jsonLd, articleIndexFaqJsonLd])}
|
||||
>
|
||||
<PageViewTracker pageType="articles" entityId={`articles-page-${currentPage}`} />
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -4,9 +4,10 @@ import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
|
||||
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
|
||||
import SharePanel from '../../components/seo/SharePanel.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -70,14 +71,16 @@ const sharePanelCopy = isEnglish
|
||||
description:
|
||||
'Share the site’s AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
|
||||
examples: 'Prompts',
|
||||
ai: 'AI',
|
||||
status: 'Status',
|
||||
statusValue: aiEnabled ? 'Ready' : 'Idle',
|
||||
}
|
||||
: {
|
||||
badge: '问答入口',
|
||||
title: '分享问答页',
|
||||
description: '把这个问答页分享给需要快速检索站内内容的人。',
|
||||
title: '把问答入口甩出去',
|
||||
description: '有人想少走弯路时,把这页递过去就行。',
|
||||
examples: '示例问题',
|
||||
ai: 'AI',
|
||||
status: '状态',
|
||||
statusValue: aiEnabled ? '随时开问' : '暂时休息',
|
||||
};
|
||||
const askHighlights = buildDiscoveryHighlights([
|
||||
t('ask.subtitle'),
|
||||
@@ -104,28 +107,41 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
|
||||
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||
siteSettings={siteSettings}
|
||||
jsonLd={[...askJsonLd, askFaqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])}
|
||||
>
|
||||
<PageViewTracker pageType="ask" entityId="ask" />
|
||||
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div>
|
||||
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
|
||||
</div>
|
||||
<div class:list={[
|
||||
'rounded-full border px-3 py-1 text-xs font-mono',
|
||||
aiEnabled
|
||||
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||
]}>
|
||||
{aiEnabled ? t('common.featureOn') : t('common.featureOff')}
|
||||
<section class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<TerminalWindow title="~/ask" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<div class="terminal-panel ml-4 mt-4 space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-sparkles"></i>
|
||||
{t('ask.terminalLabel')}
|
||||
</span>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-bold text-[var(--title-color)] sm:text-3xl">{t('ask.title')}</h1>
|
||||
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)] sm:text-base">
|
||||
{t('ask.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pt-6">
|
||||
<span class:list={[
|
||||
'terminal-stat-pill px-3 py-1.5 text-xs font-mono',
|
||||
aiEnabled
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||
: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
]}>
|
||||
<i class:list={[
|
||||
'fas',
|
||||
aiEnabled ? 'fa-wave-square' : 'fa-triangle-exclamation'
|
||||
]}></i>
|
||||
{aiEnabled ? '已待命' : '休息中'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SharePanel
|
||||
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
|
||||
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||
@@ -136,13 +152,11 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
description={sharePanelCopy.description}
|
||||
stats={[
|
||||
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
|
||||
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') },
|
||||
{ label: sharePanelCopy.status, value: sharePanelCopy.statusValue },
|
||||
]}
|
||||
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pt-6">
|
||||
<DiscoveryBrief
|
||||
badge={isEnglish ? 'ask brief' : '问答摘要'}
|
||||
kicker="geo / ai"
|
||||
@@ -151,20 +165,19 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
highlights={askHighlights}
|
||||
faqs={askFaqs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
|
||||
<div class="min-w-0">
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_18rem]">
|
||||
<div class="min-w-0 space-y-5">
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<form id="ai-form" class="terminal-panel-muted space-y-4">
|
||||
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
|
||||
<textarea
|
||||
id="ai-question"
|
||||
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
|
||||
class="min-h-[160px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
|
||||
placeholder={t('ask.textareaPlaceholder')}
|
||||
></textarea>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-terminal text-xs"></i>
|
||||
<span>{t('ask.submit')}</span>
|
||||
@@ -173,7 +186,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
|
||||
<div id="ai-result" class="terminal-panel-muted hidden">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div>
|
||||
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
|
||||
@@ -183,7 +196,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
|
||||
<div class="terminal-panel-muted border-dashed px-5 py-8">
|
||||
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.disabledStateLabel')}</div>
|
||||
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||
@@ -194,7 +207,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
{sampleQuestions.map((question) => (
|
||||
@@ -209,7 +222,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.guide')}</div>
|
||||
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
|
||||
<li>{t('ask.guide1')}</li>
|
||||
@@ -220,9 +233,255 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style is:global>
|
||||
.ask-connecting-shell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.95rem;
|
||||
min-width: min(100%, 20rem);
|
||||
border-radius: 1.1rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(var(--primary-rgb), 0.11), transparent 28%),
|
||||
radial-gradient(circle at bottom left, rgba(var(--primary-rgb), 0.05), transparent 34%),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--terminal-bg) 98%, transparent),
|
||||
color-mix(in oklab, var(--header-bg) 92%, transparent)
|
||||
);
|
||||
padding: 0.95rem 1.05rem;
|
||||
box-shadow:
|
||||
0 14px 28px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.03);
|
||||
}
|
||||
|
||||
.ask-gemini-avatar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
flex: 0 0 2.2rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(160, 211, 255, 0.24), transparent 55%),
|
||||
radial-gradient(circle at 70% 70%, rgba(var(--primary-rgb), 0.2), transparent 48%),
|
||||
color-mix(in oklab, var(--terminal-bg) 88%, rgba(var(--primary-rgb), 0.16));
|
||||
animation: ask-gemini-avatar-breathe 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ask-gemini-avatar::before,
|
||||
.ask-gemini-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.3rem;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ask-gemini-avatar::before {
|
||||
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.24), transparent 72%);
|
||||
filter: blur(9px);
|
||||
animation: ask-gemini-halo 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ask-gemini-avatar::after {
|
||||
inset: 0.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ask-gemini-orbit {
|
||||
position: absolute;
|
||||
inset: -0.52rem;
|
||||
border-radius: 999px;
|
||||
animation: ask-gemini-orbit-spin 2.15s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ask-gemini-orbit-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
transform: rotate(-90deg);
|
||||
filter:
|
||||
drop-shadow(0 0 8px rgba(var(--primary-rgb), 0.22))
|
||||
drop-shadow(0 0 16px rgba(79, 160, 255, 0.16));
|
||||
}
|
||||
|
||||
.ask-gemini-orbit-ring {
|
||||
fill: none;
|
||||
stroke: url(#ask-gemini-orbit-gradient);
|
||||
stroke-width: 2.3;
|
||||
stroke-linecap: butt;
|
||||
stroke-dasharray: 34 110;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
animation:
|
||||
ask-gemini-orbit-length 2.1s cubic-bezier(0.6, 0.08, 0.28, 0.96) infinite,
|
||||
ask-gemini-orbit-glow 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ask-gemini-svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ask-gemini-star-outline {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ask-gemini-sweep {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ask-gemini-sweep 2.9s cubic-bezier(0.55, 0.08, 0.28, 0.98) infinite;
|
||||
}
|
||||
|
||||
.ask-gemini-sweep-secondary {
|
||||
animation-delay: 1.15s;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.ask-connecting-copy {
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ask-connecting-copy span {
|
||||
display: block;
|
||||
color: color-mix(in oklab, var(--title-color) 72%, var(--text-secondary));
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-avatar-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-0.02rem) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-halo {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-orbit-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-orbit-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
filter: saturate(0.96);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: saturate(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-orbit-length {
|
||||
0% {
|
||||
stroke-dasharray: 30 102;
|
||||
stroke-dashoffset: 4;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
38% {
|
||||
stroke-dasharray: 64 68;
|
||||
stroke-dashoffset: -14;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
68% {
|
||||
stroke-dasharray: 96 36;
|
||||
stroke-dashoffset: -30;
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
88% {
|
||||
stroke-dasharray: 118 14;
|
||||
stroke-dashoffset: -48;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
96% {
|
||||
stroke-dasharray: 124 8;
|
||||
stroke-dashoffset: -56;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 30 102;
|
||||
stroke-dashoffset: -64;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ask-gemini-sweep {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(-0.6rem, 0, 0) rotate(-34deg) scaleX(0.86);
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
56% {
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0.7rem, 0, 0) rotate(-34deg) scaleX(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ask-gemini-avatar,
|
||||
.ask-gemini-avatar::before,
|
||||
.ask-gemini-orbit,
|
||||
.ask-gemini-sweep,
|
||||
.ask-gemini-sweep-secondary {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{aiEnabled && (
|
||||
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
|
||||
const t = window.__termiTranslate;
|
||||
@@ -317,6 +576,71 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function renderConnectingState(message) {
|
||||
return `
|
||||
<div class="ask-connecting-shell" role="status" aria-live="polite">
|
||||
<span class="ask-gemini-avatar" aria-hidden="true">
|
||||
<span class="ask-gemini-orbit">
|
||||
<svg class="ask-gemini-orbit-svg" viewBox="0 0 48 48" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="ask-gemini-orbit-gradient" x1="5" y1="43" x2="43" y2="5" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#2563eb"></stop>
|
||||
<stop offset="48%" stop-color="#22d3ee"></stop>
|
||||
<stop offset="78%" stop-color="#fde68a"></stop>
|
||||
<stop offset="100%" stop-color="#ffffff"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle class="ask-gemini-orbit-ring" cx="24" cy="24" r="21"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
<svg class="ask-gemini-svg" viewBox="0 0 32 32" width="32" height="32" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="ask-gemini-base" x1="5" y1="26" x2="27" y2="6" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#346bf1"></stop>
|
||||
<stop offset="22%" stop-color="#3279f8"></stop>
|
||||
<stop offset="45%" stop-color="#3186ff"></stop>
|
||||
<stop offset="72%" stop-color="#4093ff"></stop>
|
||||
<stop offset="100%" stop-color="#4fa0ff"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="ask-gemini-aurora" x1="7" y1="23" x2="25" y2="9" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"></stop>
|
||||
<stop offset="42%" stop-color="#ffffff" stop-opacity="0.96"></stop>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
<radialGradient id="ask-gemini-core" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"></stop>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
<clipPath id="ask-gemini-star-clip">
|
||||
<path d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="16" cy="16" r="11" fill="url(#ask-gemini-core)" opacity="0.22"></circle>
|
||||
<path
|
||||
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
|
||||
fill="url(#ask-gemini-base)"
|
||||
></path>
|
||||
<g clip-path="url(#ask-gemini-star-clip)">
|
||||
<rect class="ask-gemini-sweep" x="2" y="13.7" width="28" height="4.2" rx="2.1" fill="url(#ask-gemini-aurora)"></rect>
|
||||
<rect class="ask-gemini-sweep ask-gemini-sweep-secondary" x="1" y="14.7" width="30" height="2.7" rx="1.35" fill="url(#ask-gemini-aurora)"></rect>
|
||||
</g>
|
||||
<path
|
||||
class="ask-gemini-star-outline"
|
||||
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-opacity="0.55"
|
||||
stroke-width="0.5"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="ask-connecting-copy">
|
||||
<span>${escapeHtml(message || t('ask.connectingShort'))}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setInteractiveState(isLoading) {
|
||||
if (submit) {
|
||||
submit.toggleAttribute('disabled', isLoading);
|
||||
@@ -512,7 +836,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
|
||||
|
||||
setInteractiveState(true);
|
||||
result.classList.remove('hidden');
|
||||
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
|
||||
answer.innerHTML = renderConnectingState(t('ask.connectingShort'));
|
||||
sources.innerHTML = '';
|
||||
meta.textContent = '';
|
||||
updatePrompt(t('ask.promptSubmitting'));
|
||||
|
||||
@@ -20,9 +20,7 @@ const isEnglish = locale.startsWith('en');
|
||||
let categories: Category[] = [];
|
||||
let posts: Post[] = [];
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
let categoriesFailed = false;
|
||||
|
||||
try {
|
||||
const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
|
||||
api.getCategories(),
|
||||
api.getPosts(),
|
||||
@@ -32,7 +30,6 @@ try {
|
||||
if (categoriesResult.status === 'fulfilled') {
|
||||
categories = categoriesResult.value;
|
||||
} else {
|
||||
categoriesFailed = true;
|
||||
console.error('Failed to fetch categories:', categoriesResult.reason);
|
||||
}
|
||||
|
||||
@@ -45,9 +42,6 @@ try {
|
||||
if (settingsResult.status === 'fulfilled') {
|
||||
siteSettings = settingsResult.value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch category detail data:', error);
|
||||
}
|
||||
|
||||
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
|
||||
const category =
|
||||
@@ -59,8 +53,8 @@ const category =
|
||||
|
||||
if (!category) {
|
||||
return new Response(null, {
|
||||
status: categoriesFailed ? 503 : 404,
|
||||
headers: categoriesFailed ? { 'Retry-After': '120' } : undefined,
|
||||
status: categoriesResult.status !== 'fulfilled' ? 503 : 404,
|
||||
headers: categoriesResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { APIRoute } from 'astro'
|
||||
import { createApiClient, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||
|
||||
function resolveFaviconTarget(requestUrl: URL, configured: string | undefined) {
|
||||
const fallbackTarget = '/favicon.svg'
|
||||
const fallbackTarget = '/favicon-default.ico'
|
||||
const candidate = configured?.trim()
|
||||
|
||||
if (!candidate) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import FriendLinkCard from '../../components/FriendLinkCard.astro';
|
||||
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
|
||||
import type { AppFriendLink } from '../../lib/api/client';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -120,7 +120,7 @@ const friendsFaqJsonLd = buildFaqJsonLd(friendsFaqs);
|
||||
title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`}
|
||||
description={t('friends.pageDescription', { siteName: siteSettings.siteName })}
|
||||
siteSettings={siteSettings}
|
||||
jsonLd={[...friendsJsonLd, friendsFaqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([...friendsJsonLd, friendsFaqJsonLd])}
|
||||
>
|
||||
<PageViewTracker pageType="friends" entityId="friends-index" />
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -13,7 +13,7 @@ import TechStackList from '../components/TechStackList.astro';
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList, compactJsonLd } from '../lib/seo';
|
||||
import type { AppFriendLink } from '../lib/api/client';
|
||||
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
||||
@@ -119,6 +119,11 @@ const systemStats = [
|
||||
{ label: t('common.categories'), value: String(categories.length) },
|
||||
{ label: t('common.friends'), value: String(friendLinks.length) },
|
||||
];
|
||||
const heroGlanceStats = [
|
||||
{ label: t('common.posts'), value: String(allPosts.length), icon: 'fa-file-alt' },
|
||||
{ label: t('common.categories'), value: String(categories.length), icon: 'fa-folder-open' },
|
||||
{ label: t('common.tags'), value: String(tags.length), icon: 'fa-hashtag' },
|
||||
];
|
||||
|
||||
const techStack = siteSettings.techStack.map(name => ({ name }));
|
||||
const tagFrequency = new Map<string, number>();
|
||||
@@ -153,8 +158,9 @@ const activeContentRange =
|
||||
const popularRangeCards = contentRanges.flatMap((range) =>
|
||||
range.popularPosts
|
||||
.filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post))
|
||||
.map((item) => ({
|
||||
.map((item, index) => ({
|
||||
rangeKey: range.key,
|
||||
rank: index + 1,
|
||||
item,
|
||||
post: item.post,
|
||||
})),
|
||||
@@ -221,17 +227,6 @@ const discoverPrompt = hasActiveFilters
|
||||
const postsPrompt = hasActiveFilters
|
||||
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
|
||||
: t('home.promptPostsDefault', { count: previewCount });
|
||||
const popularPrompt = t('home.promptPopularRange', { label: activeContentRange.label });
|
||||
const navLinks = [
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
|
||||
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
|
||||
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
|
||||
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
|
||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||
];
|
||||
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
|
||||
const homeJsonLd = [
|
||||
{
|
||||
@@ -257,14 +252,18 @@ const homeShareCopy = isEnglish
|
||||
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
|
||||
}
|
||||
: {
|
||||
badge: '首页',
|
||||
title: '分享首页',
|
||||
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
|
||||
badge: '入口页',
|
||||
title: '把首页甩出去',
|
||||
description: '不知道发什么时,先发这个入口。轻松、不剧透,还挺省心。',
|
||||
};
|
||||
const homeShareSummary = isEnglish
|
||||
? 'A light entry point for curious visitors. Click around and let the rest reveal itself.'
|
||||
: '这是一个适合顺手转发的小入口,先逛逛,细节留到点开再说。';
|
||||
const homeSidebarCopy = isEnglish
|
||||
? {
|
||||
quickLinks: 'Quick links',
|
||||
quickLinksDesc: 'Jump to the main sections of the site.',
|
||||
quickLinksDesc: 'Keep the main channels pinned on the right so you can switch context without losing reading flow.',
|
||||
quickLinksMore: 'More channels',
|
||||
popularTitle: 'Hot now',
|
||||
popularDesc: 'Track the most-read content in the selected window.',
|
||||
friendsTitle: 'Friend links',
|
||||
@@ -275,15 +274,39 @@ const homeSidebarCopy = isEnglish
|
||||
}
|
||||
: {
|
||||
quickLinks: '快速入口',
|
||||
quickLinksDesc: '把常用入口收进侧栏,首页阅读流更清爽。',
|
||||
quickLinksDesc: '常用入口都放这儿,手别忙,点就行。',
|
||||
quickLinksMore: '更多频道',
|
||||
popularTitle: '最近热门',
|
||||
popularDesc: '按当前时间窗口查看最受关注的内容。',
|
||||
popularDesc: '看看最近是谁在悄悄抢镜。',
|
||||
friendsTitle: '友情链接',
|
||||
friendsDesc: '先看几个常访问的站点入口。',
|
||||
friendsDesc: '隔壁摊位也许也有好东西。',
|
||||
statsTitle: '站点概览',
|
||||
statsDesc: '快速看一下当前站点规模与内容状态。',
|
||||
aiBriefTitle: '站点摘要',
|
||||
statsDesc: '轻量围观一下站内气氛,不必知道太多。',
|
||||
aiBriefTitle: '站点便签',
|
||||
};
|
||||
const homeAboutSectionCopy = isEnglish
|
||||
? {
|
||||
techDesc: 'The main tools and frameworks currently carrying the site and its content flow.',
|
||||
metricsDesc: 'Site scale and basic signals, shown with the same terminal panel language as the rest of the page.',
|
||||
apiAlert: 'home / api alert',
|
||||
}
|
||||
: {
|
||||
techDesc: '当前页面和内容背后常用的技术组合。',
|
||||
metricsDesc: '用同一套终端面板看站内规模,不再突然切回普通信息块。',
|
||||
apiAlert: 'home / api alert',
|
||||
};
|
||||
const primaryQuickLinks = [
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||
];
|
||||
const secondaryQuickLinks = [
|
||||
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
|
||||
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
|
||||
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
|
||||
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
|
||||
];
|
||||
const homeBriefHighlights = buildDiscoveryHighlights([
|
||||
siteSettings.siteDescription,
|
||||
siteSettings.heroSubtitle,
|
||||
@@ -314,7 +337,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
siteSettings={siteSettings}
|
||||
canonical="/"
|
||||
noindex={hasActiveFilters}
|
||||
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([...homeJsonLd, homeFaqJsonLd])}
|
||||
>
|
||||
<PageViewTracker pageType="home" entityId="homepage" />
|
||||
<div class="mx-auto max-w-[1480px] px-4 py-6 sm:px-6 lg:px-8">
|
||||
@@ -325,10 +348,28 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
|
||||
<div class="mb-6 px-4">
|
||||
<CommandPrompt command={t('home.promptWelcome')} />
|
||||
<div class="ml-4 home-hero-shell">
|
||||
<div class="min-w-0">
|
||||
<div class="ml-4 home-hero-shell home-hero-shell--panel">
|
||||
<div class="home-hero-copy">
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-terminal"></i>
|
||||
home / overview
|
||||
</span>
|
||||
<p class="mb-1 text-lg font-bold text-[var(--primary)]">{siteSettings.heroTitle}</p>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
|
||||
|
||||
<div class="home-hero-glance">
|
||||
{heroGlanceStats.map((item) => (
|
||||
<div class="home-hero-glance-item">
|
||||
<span class="home-hero-glance-icon">
|
||||
<i class={`fas ${item.icon} text-[11px]`}></i>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||
<div class="text-sm font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-hero-meta">
|
||||
@@ -355,13 +396,19 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
|
||||
{apiError && (
|
||||
<div class="mb-8 px-4">
|
||||
<div class="ml-4 p-4 rounded-lg border border-[var(--danger)]/20 bg-[var(--danger)]/10 text-[var(--danger)] text-sm">
|
||||
{apiError}
|
||||
<div class="terminal-panel-muted ml-4 flex items-start gap-3 border-[color:color-mix(in_oklab,var(--danger)_24%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--danger)_8%,var(--terminal-bg)),color-mix(in_oklab,var(--danger)_4%,var(--header-bg)))] px-4 py-4 text-sm text-[var(--danger)]">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[color:color-mix(in_oklab,var(--danger)_24%,var(--border-color))] bg-[color:color-mix(in_oklab,var(--danger)_12%,var(--terminal-bg))]">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</span>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-[11px] font-mono uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{homeAboutSectionCopy.apiAlert}</p>
|
||||
<p class="leading-7 text-[var(--danger)]">{apiError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid gap-6 px-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="grid gap-6 px-4 xl:grid-cols-[minmax(0,1fr)_320px] 2xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div class="min-w-0 space-y-6">
|
||||
<div id="discover">
|
||||
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
|
||||
@@ -586,22 +633,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4 xl:sticky xl:top-24 xl:self-start">
|
||||
<section class="terminal-panel space-y-4">
|
||||
<aside class="home-sidebar-stack">
|
||||
<section class="terminal-panel home-sidebar-card home-sidebar-card--quickmenu space-y-4 xl:sticky xl:top-24">
|
||||
<div class="space-y-1">
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-compass"></i>
|
||||
quick menu
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinks}</h3>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.quickLinksDesc}</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
|
||||
{navLinks.map(link => (
|
||||
<div class="home-sidebar-grid">
|
||||
{primaryQuickLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="group flex min-w-0 items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-3 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
class="home-sidebar-link group flex min-w-0 items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-2.5 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
>
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
<span class="home-sidebar-link__icon">
|
||||
<i class={`fas ${link.icon} text-[11px]`}></i>
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-medium">{link.text}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{link.text}</span>
|
||||
<i class="fas fa-arrow-right text-[10px] text-[var(--text-tertiary)] transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-[var(--primary)]"></i>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 border-t border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] pt-4">
|
||||
<h4 class="text-sm font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinksMore}</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
|
||||
{secondaryQuickLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="home-sidebar-mini-link group flex min-w-0 items-center gap-2.5 rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 px-3 py-2 text-xs text-[var(--text-secondary)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
>
|
||||
<span class="home-sidebar-mini-link__icon">
|
||||
<i class={`fas ${link.icon} text-[10px]`}></i>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{link.text}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -609,7 +679,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
|
||||
<SharePanel
|
||||
shareTitle={siteSettings.siteTitle}
|
||||
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
||||
summary={homeShareSummary}
|
||||
canonicalUrl={siteBaseUrl}
|
||||
badge={homeShareCopy.badge}
|
||||
title={homeShareCopy.title}
|
||||
@@ -618,22 +688,26 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
variant="compact"
|
||||
/>
|
||||
|
||||
<section class="terminal-panel space-y-4">
|
||||
<section class="terminal-panel home-sidebar-card space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-fire"></i>
|
||||
traffic
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.popularTitle}</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.popularDesc}</p>
|
||||
</div>
|
||||
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
|
||||
</div>
|
||||
|
||||
<div class="home-popular-sortbar">
|
||||
<div class="home-popular-rangebar">
|
||||
{popularRangeOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
data-home-popular-range={option.key}
|
||||
class:list={[
|
||||
'home-popular-sort',
|
||||
'home-popular-range',
|
||||
option.key === activeContentRange.key && 'is-active'
|
||||
]}
|
||||
>
|
||||
@@ -644,7 +718,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
</div>
|
||||
|
||||
<div id="home-popular-list" class="space-y-3">
|
||||
{popularRangeCards.map(({ rangeKey, item, post }) => (
|
||||
{popularRangeCards.map(({ rangeKey, rank, item, post }) => (
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
data-home-popular-card
|
||||
@@ -655,16 +729,15 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
data-home-slug={post.slug}
|
||||
data-home-popular-views={item.pageViews}
|
||||
data-home-popular-completes={item.readCompletes}
|
||||
data-home-popular-depth={Math.round(item.avgProgressPercent)}
|
||||
class:list={[
|
||||
'block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]',
|
||||
'home-sidebar-popular block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]',
|
||||
!initialPopularVisibleKeys.has(`${rangeKey}:${post.slug}`) && 'hidden'
|
||||
]}
|
||||
style={getAccentVars(getPostTypeTheme(post.type))}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
|
||||
{item.pageViews}
|
||||
<span class="home-sidebar-popular__rank">
|
||||
<span data-home-popular-rank-label>{rank}</span>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -676,7 +749,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-2 line-clamp-2 text-sm font-semibold leading-6 text-[var(--title-color)]">{post.title}</h4>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<div class="home-sidebar-popular__metrics">
|
||||
<span>{t('home.views')}: {item.pageViews}</span>
|
||||
<span>{t('home.completes')}: {item.readCompletes}</span>
|
||||
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
|
||||
@@ -692,9 +765,13 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="terminal-panel space-y-4">
|
||||
<section class="terminal-panel home-sidebar-card space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-chart-simple"></i>
|
||||
stats
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.statsTitle}</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.statsDesc}</p>
|
||||
</div>
|
||||
@@ -702,41 +779,45 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
|
||||
<p id="home-reading-views-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
|
||||
<p id="home-reading-completes-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
|
||||
<p id="home-reading-progress-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
|
||||
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
|
||||
<p id="home-reading-duration-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="home-sidebar-meta-list">
|
||||
{systemStats.slice(0, 4).map((item) => (
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||
<div class="home-sidebar-meta-item">
|
||||
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</span>
|
||||
<span class="text-sm font-semibold text-[var(--title-color)]">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sidebarFriendLinks.length > 0 && (
|
||||
<section class="terminal-panel space-y-4">
|
||||
<section class="terminal-panel home-sidebar-card space-y-4">
|
||||
<div class="space-y-1">
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-link"></i>
|
||||
network
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.friendsTitle}</h3>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.friendsDesc}</p>
|
||||
</div>
|
||||
@@ -747,7 +828,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
href={friend.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group flex items-start gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
|
||||
class="home-sidebar-friend group flex items-start gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
|
||||
>
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
@@ -790,26 +871,61 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
<div id="about" class="px-4">
|
||||
<CommandPrompt command={t('home.promptAbout')} />
|
||||
<div class="ml-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.22fr)_minmax(18rem,0.78fr)]">
|
||||
<section class="terminal-panel space-y-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-user-astronaut"></i>
|
||||
home / profile
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3>
|
||||
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
|
||||
<h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.about')}</h3>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{siteSettings.ownerBio}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.techStack')}</h3>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-code-branch text-[var(--primary)]"></i>
|
||||
{t('common.postsCount', { count: allPosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-[var(--title-color)]">{t('home.techStack')}</h4>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{homeAboutSectionCopy.techDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<TechStackList items={techStack} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="terminal-panel space-y-4">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker w-fit">
|
||||
<i class="fas fa-chart-column"></i>
|
||||
home / metrics
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3>
|
||||
<StatsList stats={systemStats} />
|
||||
<h3 class="text-xl font-bold text-[var(--title-color)]">{t('home.systemStatus')}</h3>
|
||||
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{homeAboutSectionCopy.metricsDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatsList stats={systemStats} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-8 border-t border-[var(--border-color)]"></div>
|
||||
|
||||
<div class="px-4 pb-2">
|
||||
<div id="site-brief" class="px-4 pb-2">
|
||||
<div class="ml-4">
|
||||
<DiscoveryBrief
|
||||
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}
|
||||
@@ -874,7 +990,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
const popularList = document.getElementById('home-popular-list');
|
||||
const popularCount = document.getElementById('home-popular-count');
|
||||
const popularRangeButtons = Array.from(document.querySelectorAll('[data-home-popular-range]'));
|
||||
const popularSortButtons = Array.from(document.querySelectorAll('[data-home-popular-sort]'));
|
||||
const readingWindowPill = document.getElementById('home-stats-window-pill');
|
||||
const readingViewsValue = document.getElementById('home-reading-views-value');
|
||||
const readingCompletesValue = document.getElementById('home-reading-completes-value');
|
||||
@@ -902,7 +1017,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
category: initialHomeState.category || '',
|
||||
tag: initialHomeState.tag || '',
|
||||
range: initialHomeState.range || '7d',
|
||||
popularSort: 'views',
|
||||
};
|
||||
|
||||
function getActiveRange() {
|
||||
@@ -928,37 +1042,20 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
});
|
||||
}
|
||||
|
||||
function syncPopularSortButtons() {
|
||||
popularSortButtons.forEach((button) => {
|
||||
button.classList.toggle(
|
||||
'is-active',
|
||||
(button.getAttribute('data-home-popular-sort') || 'views') === state.popularSort
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sortPopularCards(cards) {
|
||||
const metricKey =
|
||||
state.popularSort === 'completes'
|
||||
? 'data-home-popular-completes'
|
||||
: state.popularSort === 'depth'
|
||||
? 'data-home-popular-depth'
|
||||
: 'data-home-popular-views';
|
||||
|
||||
return [...cards].sort((left, right) => {
|
||||
const leftValue = Number(left.getAttribute(metricKey) || '0');
|
||||
const rightValue = Number(right.getAttribute(metricKey) || '0');
|
||||
|
||||
if (rightValue !== leftValue) {
|
||||
return rightValue - leftValue;
|
||||
}
|
||||
|
||||
const leftViews = Number(left.getAttribute('data-home-popular-views') || '0');
|
||||
const rightViews = Number(right.getAttribute('data-home-popular-views') || '0');
|
||||
if (rightViews !== leftViews) {
|
||||
return rightViews - leftViews;
|
||||
}
|
||||
|
||||
const leftCompletes = Number(left.getAttribute('data-home-popular-completes') || '0');
|
||||
const rightCompletes = Number(right.getAttribute('data-home-popular-completes') || '0');
|
||||
if (rightCompletes !== leftCompletes) {
|
||||
return rightCompletes - leftCompletes;
|
||||
}
|
||||
|
||||
return String(left.getAttribute('data-home-slug') || '').localeCompare(
|
||||
String(right.getAttribute('data-home-slug') || '')
|
||||
);
|
||||
@@ -975,18 +1072,14 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
|
||||
function syncPromptText(total) {
|
||||
const tokens = getActiveTokens();
|
||||
const activeRange = getActiveRange();
|
||||
const discoverCommand = tokens.length
|
||||
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
|
||||
: t('home.promptDiscoverDefault');
|
||||
const postsCommand = tokens.length
|
||||
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
|
||||
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) });
|
||||
const popularCommand = t('home.promptPopularRange', { label: activeRange.label || state.range });
|
||||
|
||||
promptApi?.set?.('home-discover-prompt', discoverCommand, { typing: false });
|
||||
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
|
||||
promptApi?.set?.('home-popular-prompt', popularCommand, { typing: false });
|
||||
}
|
||||
|
||||
function syncRangeMetrics(filteredPopularCount) {
|
||||
@@ -1144,8 +1237,12 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
});
|
||||
const sortedPopular = sortPopularCards(filteredPopular);
|
||||
popularCards.forEach((card) => card.classList.add('hidden'));
|
||||
sortedPopular.slice(0, popularPreviewLimit).forEach((card) => {
|
||||
sortedPopular.slice(0, popularPreviewLimit).forEach((card, index) => {
|
||||
card.classList.remove('hidden');
|
||||
const rankLabel = card.querySelector('[data-home-popular-rank-label]');
|
||||
if (rankLabel) {
|
||||
rankLabel.textContent = String(index + 1);
|
||||
}
|
||||
popularList?.appendChild(card);
|
||||
});
|
||||
|
||||
@@ -1172,7 +1269,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
syncActiveSummary();
|
||||
syncPostTagSelection();
|
||||
syncPopularRangeButtons();
|
||||
syncPopularSortButtons();
|
||||
syncRangeMetrics(filteredPopular.length);
|
||||
syncPromptText(total);
|
||||
|
||||
@@ -1224,13 +1320,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
||||
});
|
||||
});
|
||||
|
||||
popularSortButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
state.popularSort = button.getAttribute('data-home-popular-sort') || 'views';
|
||||
applyHomeFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
postsRoot?.addEventListener('click', (event) => {
|
||||
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
|
||||
if (!target) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
|
||||
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
|
||||
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -286,7 +286,7 @@ const reviewFaqJsonLd = buildFaqJsonLd(reviewFaqs);
|
||||
siteSettings={siteSettings}
|
||||
canonical={hasActiveFilters ? '/reviews' : undefined}
|
||||
noindex={hasActiveFilters}
|
||||
jsonLd={[...reviewsJsonLd, reviewFaqJsonLd].filter(Boolean)}
|
||||
jsonLd={compactJsonLd([...reviewsJsonLd, reviewFaqJsonLd])}
|
||||
>
|
||||
<PageViewTracker pageType="reviews" entityId="reviews-index" />
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -20,9 +20,7 @@ const isEnglish = locale.startsWith('en');
|
||||
let tags: Tag[] = [];
|
||||
let posts: Post[] = [];
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
let tagsFailed = false;
|
||||
|
||||
try {
|
||||
const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
|
||||
apiClient.getTags(),
|
||||
apiClient.getPosts(),
|
||||
@@ -32,7 +30,6 @@ try {
|
||||
if (tagsResult.status === 'fulfilled') {
|
||||
tags = tagsResult.value;
|
||||
} else {
|
||||
tagsFailed = true;
|
||||
console.error('Failed to fetch tags:', tagsResult.reason);
|
||||
}
|
||||
|
||||
@@ -45,9 +42,6 @@ try {
|
||||
if (settingsResult.status === 'fulfilled') {
|
||||
siteSettings = settingsResult.value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag detail data:', error);
|
||||
}
|
||||
|
||||
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
|
||||
const tag =
|
||||
@@ -59,8 +53,8 @@ const tag =
|
||||
|
||||
if (!tag) {
|
||||
return new Response(null, {
|
||||
status: tagsFailed ? 503 : 404,
|
||||
headers: tagsFailed ? { 'Retry-After': '120' } : undefined,
|
||||
status: tagsResult.status !== 'fulfilled' ? 503 : 404,
|
||||
headers: tagsResult.status !== 'fulfilled' ? { 'Retry-After': '120' } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -433,7 +433,7 @@ function getServer() {
|
||||
},
|
||||
async () => {
|
||||
const data = await requestBackend('POST', '/ai/reindex');
|
||||
return createToolResult('AI index rebuilt', data);
|
||||
return createToolResult('AI index rebuild job queued', data);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
68
playwright-smoke/playwright.web-push.config.ts
Normal file
68
playwright-smoke/playwright.web-push.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
122
playwright-smoke/tests/web-push.real.spec.ts
Normal file
122
playwright-smoke/tests/web-push.real.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user