Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled

This commit is contained in:
2026-04-04 04:15:20 +08:00
parent ab18bbaf23
commit 381dc9b854
19 changed files with 1607 additions and 747 deletions

View File

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

View File

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

View File

@@ -114,3 +114,14 @@ export function formatWorkerProgress(
return progress.message ?? (details || null);
}
export function getWorkerProgressPercent(
job: Pick<WorkerJobRecord, "result">,
): number | null {
const progress = getWorkerProgress(job);
if (typeof progress?.percent !== "number") {
return null;
}
return Math.max(0, Math.min(100, Math.round(progress.percent)));
}

View File

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

View File

@@ -26,7 +26,11 @@ import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { adminApi, ApiError } from "@/lib/api";
import { formatWorkerProgress } from "@/lib/worker-progress";
import { formatDateTime } from "@/lib/admin-format";
import {
formatWorkerProgress,
getWorkerProgressPercent,
} from "@/lib/worker-progress";
import type {
AdminSiteSettingsResponse,
AiProviderConfig,
@@ -2119,7 +2123,9 @@ export function SiteSettingsPage() {
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{form.ai_last_indexed_at ?? "索引尚未建立。"}
{form.ai_last_indexed_at
? formatDateTime(form.ai_last_indexed_at)
: "索引尚未建立。"}
</p>
{reindexJobId ? (
<p className="mt-3 text-xs leading-6 text-muted-foreground">
@@ -2133,6 +2139,18 @@ export function SiteSettingsPage() {
"任务已经开始,正在等待下一次进度更新。"}
</p>
) : null}
{reindexJobResult &&
getWorkerProgressPercent({ result: reindexJobResult }) !==
null ? (
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{
width: `${getWorkerProgressPercent({ result: reindexJobResult })}%`,
}}
/>
</div>
) : null}
</div>
</CardContent>
</Card>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2537,6 +2537,18 @@ async fn update_reindex_job_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,
@@ -2729,6 +2741,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
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?;
txn.execute(Statement::from_string(
@@ -2740,6 +2753,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
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()
@@ -2799,6 +2813,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
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?;

View File

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

View File

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

View File

@@ -614,6 +614,20 @@ pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Resu
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();
@@ -708,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,

View File

@@ -55,6 +55,16 @@ impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
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)
}

View File

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

View File

@@ -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
}
}

View File

@@ -1,4 +1,4 @@
self.addEventListener('push', (event) => {
self.addEventListener("push", (event) => {
const payload = (() => {
if (!event.data) {
return {};
@@ -13,39 +13,59 @@ 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));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification?.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client && client.url === targetUrl) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
return undefined;
}),
Promise.all([
self.registration.showNotification(title, options),
notifyClients,
]),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification?.data?.url || "/";
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ("focus" in client && client.url === targetUrl) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
return undefined;
}),
);
});

View File

@@ -360,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';
@@ -505,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;
@@ -811,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({

View File

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

View File

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

View File

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