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
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:
@@ -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
|
||||
|
||||
@@ -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 镜像)
|
||||
|
||||
补充部署分层与反代说明见:
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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